// // RadialNavigationView.swift // Haru_Danim // // [실험적] iPhone 전용 플로팅 내비게이션 (설정 → 내비게이션 방식에서 전환). // // 구조: // - 하단 시스템 탭바를 완전히 숨긴 TabView로 7개 탭의 화면·내비게이션 상태를 그대로 보존하고 // (탭 전환은 중앙 FAB 메뉴가 담당), // - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 메뉴 오버레이를 얹는다. // → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다. // // 메뉴 UI: '글래스 버블(Glass Bubbles)' — 시스템 리퀴드 글라스(glassEffect) 기반. // - FAB를 누르면 진짜 유리 재질(.regular.interactive — 배경 굴절·빛 반사·눌림 시머까지 // 시스템이 렌더링)의 구슬 버튼들이 FAB에서 부채꼴 아치로 회전하며 퍼져 나온다. // - 모든 유리 요소를 하나의 GlassEffectContainer에 담아 한 패스로 그린다: 예전의 // material 블러 14장(버블 7 + 라벨 알약 7) + 그림자 14개 개별 애니메이션이 버벅임의 // 원인이었다. 라벨을 구슬 안에 넣어 유리 요소를 8개(버블 7 + FAB)로 줄였고, 그림자는 // 유리 자체 깊이감에 맡긴다. 컨테이너 덕분에 버블이 FAB 근처를 지날 때 유리가 // 액체처럼 늘어나며 분리·합체되는 모핑이 함께 생긴다. // - 등장: 스태거(순차) 바운스 스프링 + 스월 회전. 닫힘: 지연 없이 FAB로 스냅 흡수. // 펼치면 메뉴 뒤로 은은한 초록 빛 무리(정적 그라데이션이라 렌더 비용 없음)가 퍼진다. // - 아치 반경이 엄지 반경(~150pt) 안이라 세로 스택과 달리 모든 탭이 한 손에 닿는다. // - iOS 18 폴백(1.4 하위 OS 지원): glassEffect 계열은 26 전용이라, 18에서는 같은 구슬을 // 머티리얼 원(블러+하이라이트 테두리+그림자)으로 그린다 — 굴절·액체 모핑만 빠지고 // 배치·스태거 스프링·더블탭 등 동작은 완전히 동일하다 (LiquidBallBackground 참고). // // iPhone(Compact) 전용 — iPad·Mac은 SidebarRootView, watch는 별도 타깃이라 이 파일과 무관하다. // import SwiftUI // MARK: - 컨테이너 struct RadialTabView: View { @State private var router: AppRouter @State private var menuExpanded = false init() { let router = AppRouter() // 버블 메뉴에는 모든 탭이 노출되므로 '더보기' 우회 라우팅을 쓰지 않는다. router.allTabsDirect = true _router = State(initialValue: router) #if DEBUG // 검증용: -radialExpanded YES → 펼쳐진 상태로 시작(시뮬레이터에서 탭 없이 스크린샷) if UserDefaults.standard.bool(forKey: "radialExpanded") { _menuExpanded = State(initialValue: true) } #endif } var body: some View { ZStack { // 1) 탭 화면 본체 — 시스템 탭바만 숨기고 TabView가 각 탭의 스택 상태를 보존한다. TabView(selection: $router.tabSelection) { ForEach(AppTab.phoneCases) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { // FAB(하단 42pt + 구슬 64pt)가 항상 떠 있으므로 스크롤 콘텐츠의 // 끝이 그 위로 올라오게 하단 인셋을 준다 — 없으면 목록 마지막 행이 // FAB에 가려지고 중앙 탭이 메뉴 열기로 새는 실측 문제(1.4(2) 검수). // ⚠️ NavigationStack '안쪽'에 붙일 것 — Tab 콘텐츠(스택 바깥)에 // 붙이면 TabView가 safe area를 재설정해 무시된다(실측) tab.rootView .safeAreaInset(edge: .bottom) { Color.clear.frame(height: 78) } } .toolbar(.hidden, for: .tabBar) } } } // 2) 글래스 버블 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. GlassBubbleMenu( expanded: $menuExpanded, selection: $router.tabSelection ) } .environment(router) } } // MARK: - 글래스 버블 메뉴 오버레이 struct GlassBubbleMenu: View { @Binding var expanded: Bool /// AppTab.rawValue (라우터의 tabSelection과 직접 연결) @Binding var selection: String /// 사용자가 설정에서 지정한 버블 배치 순서 (기기 로컬, 비동기화) @AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = "" /// '두 번 눌러 바로 이동' 대상 탭 (rawValue, 빈 값 = 꺼짐 — 기기 로컬, 비동기화) @AppStorage(SettingsKeys.radialDoubleTapTab) private var doubleTapTabRaw = "" /// 닫힌 상태에서 FAB를 눌러 메뉴를 연 시각. 이 시각부터 창(doubleTapWindow) 안에 /// FAB를 한 번 더 누르면 더블탭으로 판정한다. 첫 탭을 지연시키는 제스처 인식 방식은 /// 메뉴 열림 반응성을 해치므로, 첫 탭은 즉시 열고 두 번째 탭에서만 분기한다 /// (더블탭 시 버블이 피어나다 FAB로 흡수되며 화면이 바뀌는 연출이 됨). @State private var openTapAt: Date? private let doubleTapWindow: TimeInterval = 0.35 /// 리퀴드 글라스 모핑 네임스페이스 — FAB와 버블이 같은 유리 덩어리로 이어지게 한다 @Namespace private var glassSpace /// 2단계 연출의 두 번째 상태: 버블이 FAB에서 아치 위 자기 자리로 퍼져 나갔는지. /// expanded(삽입·모핑)와 분리한 이유 — GlassEffectContainer 안의 유리 요소는 /// glassEffect 뒤의 opacity/scale이 콘텐츠에 온전히 적용되지 않아(접힌 상태에서 /// 내용물이 비쳐 보이는 문제), 숨김은 계층 제거(if expanded)로, 이동은 검증된 /// offset 애니메이션(bloomed)으로만 처리한다. @State private var bloomed = false // 레이아웃 상수 private let fabSize: CGFloat = 64 private let bubbleSize: CGFloat = 58 /// FAB 중심 → 버블 중심 거리. 엄지가 자연스럽게 닿는 반경 안으로 유지한다. private let desiredRadius: CGFloat = 152 private let bottomInset: CGFloat = 42 private let edgePadding: CGFloat = 14 /// 유리가 액체처럼 합쳐지기 시작하는 거리. 정지 상태의 이웃 버블 간격(~20pt)보다 /// 살짝 작게 잡아, 아치에 자리 잡으면 또렷이 분리되고 FAB 근처를 지날 때만 모핑한다. private let glassBlendSpacing: CGFloat = 18 /// 왼쪽 끝 → 위 → 오른쪽 끝 순으로 배치할 탭 (설정의 커스텀 순서를 따른다) private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) } private var currentTab: AppTab { AppTab(rawValue: selection) ?? .main } var body: some View { GeometryReader { geo in // 양 끝 버블이 화면 밖으로 잘리지 않도록 반경을 화면 폭에 맞춰 제한 let radius = min(desiredRadius, geo.size.width / 2 - bubbleSize / 2 - edgePadding) ZStack { dimLayer // FAB(하단 중앙)를 기준점으로 빛 무리와 버블들이 부채꼴로 퍼진다. ZStack { glow(radius: radius) // 모든 유리 요소를 한 컨테이너에 담아 한 패스로 렌더링 + 근접 시 액체 모핑. // iOS 18은 컨테이너가 없어 같은 클러스터를 그대로 그린다(모핑만 없음). // 26 API 직접 호출 금지 — LiquidGlassBall 주석의 컴파일러 우회 규칙 참고 if #available(iOS 26.0, *) { GlassClusterContainer(spacing: glassBlendSpacing) { bubbleCluster(radius: radius) } } else { bubbleCluster(radius: radius) } } .frame(width: fabSize, height: fabSize) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .padding(.bottom, bottomInset) } } .ignoresSafeArea(.keyboard) .sensoryFeedback(.impact(weight: .light), trigger: expanded) .onAppear { // -radialExpanded 등으로 펼쳐진 채 시작하면 버블도 자리 잡은 상태로 if expanded { bloomed = true } #if DEBUG // 검증용: -radialDoubleTapTest YES → 2초 뒤 FAB 더블탭을 재현 // (설정 키는 -settings.radialDoubleTapTab <탭>으로 주입) if UserDefaults.standard.bool(forKey: "radialDoubleTapTest") { Task { @MainActor in try? await Task.sleep(for: .seconds(2)) toggle() try? await Task.sleep(for: .milliseconds(180)) toggle() } } #endif } } // MARK: 배경 레이어 /// 바깥 영역 탭 → 닫기. 아래쪽(메뉴 주변)이 더 어두운 그라데이션 딤으로 /// 유리 구슬의 굴절이 도드라지게 한다. 펼쳐졌을 때만 히트테스트를 받는다. private var dimLayer: some View { LinearGradient( colors: [.black.opacity(0.06), .black.opacity(0.32)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() .opacity(expanded ? 1 : 0) .animation(.easeOut(duration: 0.22), value: expanded) .allowsHitTesting(expanded) .onTapGesture { close() } } /// 펼쳤을 때 메뉴 뒤로 은은히 퍼지는 초록 빛 무리 — 유리가 빛을 머금은 느낌. /// 정적 그라데이션 한 장이라 렌더 비용이 사실상 없다. private func glow(radius: CGFloat) -> some View { RadialGradient( colors: [AppTheme.green.opacity(0.22), AppTheme.green.opacity(0.05), .clear], center: .center, startRadius: 0, endRadius: radius * 1.3 ) .frame(width: radius * 2.6, height: radius * 2.6) .scaleEffect(expanded ? 1 : 0.15) .opacity(expanded ? 1 : 0) .animation(.spring(response: 0.5, dampingFraction: 0.8), value: expanded) .allowsHitTesting(false) } /// FAB + 버블 클러스터 — 26의 GlassEffectContainer 안팎에서 같은 계층을 쓴다 private func bubbleCluster(radius: CGFloat) -> some View { ZStack { if expanded { ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in bubble(tab, index: index, count: tabs.count, radius: radius) } } fab } } // MARK: 유리 구슬 버튼 /// 탭 하나를 리퀴드 글라스 구슬로 렌더링 — 아이콘과 이름이 한 구슬 안에 있어 /// 라벨 알약이 따로 필요 없고 탭 타깃도 하나로 명확하다. /// 연출: 삽입 순간 FAB 유리에서 모핑으로 부풀어 나온 뒤(offset 0 = FAB와 한 덩어리), /// bloomed가 켜지면 스태거 스프링으로 자기 자리로 퍼진다. 블렌드 거리를 벗어나는 /// 순간 유리가 늘어나다 물방울처럼 똑 떨어져 나온다. private func bubble(_ tab: AppTab, index: Int, count: Int, radius: CGFloat) -> some View { // 왼쪽(180°) → 위(90°) → 오른쪽(0°)으로 이어지는 위쪽 반원에 균등 배치 let t = count > 1 ? Double(index) / Double(count - 1) : 0.5 let angle = Double.pi - t * Double.pi // π … 0 let dx = CGFloat(cos(angle)) * radius let dy = -CGFloat(sin(angle)) * radius // 화면 좌표는 아래가 +Y → 위로 올리려 음수 let isCurrent = tab == currentTab return Button { select(tab) } label: { VStack(spacing: 2) { Image(systemName: tab.symbol) .font(.system(size: 19, weight: .semibold)) Text(tab.label) .font(.system(size: 9.5, weight: .semibold)) .lineLimit(1) .minimumScaleFactor(0.75) } .foregroundStyle(isCurrent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) // FAB와 겹쳐 있는 동안은 내용물을 숨겨 유리 덩어리만 보이게 한다 // (glassEffect '앞'이라 콘텐츠에만 적용되고 유리 모양에는 영향 없음) .opacity(bloomed ? 1 : 0) .frame(width: bubbleSize, height: bubbleSize) .contentShape(Circle()) } .buttonStyle(.plain) // 진짜 유리(26) 또는 머티리얼 원(18): 현재 탭은 양쪽 모두 초록으로 강조. .modifier(LiquidBallBackground(tinted: isCurrent, morphID: tab.rawValue, namespace: glassSpace)) .offset(x: bloomed ? dx : 0, y: bloomed ? dy : 0) // 펼칠 때만 안쪽부터 차례로 맺히는 스태거 + 감쇠 낮은 스프링(오버슈트로 액체 출렁임). // 되돌아갈 땐 지연 없이 한 번에 FAB로 흡수된다. .animation( bloomed ? .spring(response: 0.45, dampingFraction: 0.6).delay(Double(index) * 0.04) : .spring(response: 0.26, dampingFraction: 0.9), value: bloomed ) .allowsHitTesting(expanded && bloomed) .accessibilityLabel(Text(tab.label)) } // MARK: 중앙 플로팅 버튼 private var fab: some View { Button { toggle() } label: { Image(systemName: expanded ? "xmark" : currentTab.symbol) .font(.system(size: expanded ? 21 : 23, weight: .semibold)) .foregroundStyle(AppTheme.green) .contentTransition(.symbolEffect(.replace)) .frame(width: fabSize, height: fabSize) .contentShape(Circle()) } .buttonStyle(.plain) .modifier(LiquidBallBackground(tinted: false, morphID: "fab", namespace: glassSpace)) // 펼치면 살짝 커져 '유리가 부풀며 방울을 내보내는' 기준점이 된다 .scaleEffect(expanded ? 1.07 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: expanded) .accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기")) } // MARK: 동작 private func toggle() { if expanded { // 방금(창 안에) 닫힌 상태에서 연 메뉴라면 이 탭은 더블탭 — 바로 이동 if let tappedAt = openTapAt, Date.now.timeIntervalSince(tappedAt) < doubleTapWindow, let tab = AppTab(rawValue: doubleTapTabRaw) { openTapAt = nil selection = tab.rawValue close() return } openTapAt = nil close() } else { openTapAt = doubleTapTabRaw.isEmpty ? nil : .now open() } } /// 1단계: 버블 삽입 — FAB 유리에서 모핑으로 부풀어 나온다 (offset 0, FAB와 한 덩어리). /// 2단계: 다음 런루프에서 bloomed → 스태거 스프링으로 아치에 흩어진다. /// (같은 트랜잭션에서 두 상태를 함께 바꾸면 삽입 애니메이션이 이동을 삼켜 버려 분리한다) private func open() { withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { expanded = true } Task { @MainActor in guard expanded else { return } bloomed = true } } /// 닫기: 지연 없이 흡수 — 제거 모핑과 복귀 스프링이 함께 FAB로 빨려 들어간다. private func close() { withAnimation(.spring(response: 0.26, dampingFraction: 0.9)) { bloomed = false expanded = false } } private func select(_ tab: AppTab) { // 스펙: 버블을 누르면 즉시 라우팅하고 메뉴는 닫힌다. selection = tab.rawValue close() } } // MARK: - 구슬 배경 (26 리퀴드 글라스 / 18 머티리얼 폴백) /// 26 전용 컨테이너 래퍼 — LiquidGlassBall과 같은 이유로 선언을 @available로 격리 @available(iOS 26.0, *) private struct GlassClusterContainer: View { let spacing: CGFloat @ViewBuilder let content: Content var body: some View { GlassEffectContainer(spacing: spacing) { content } } } /// 유리 구슬 배경 — iOS 26: 시스템 리퀴드 글라스(.regular.interactive — 배경 굴절·빛 반사· /// 눌림 시머) + glassEffectID로 FAB와 한 덩어리 모핑. 현재 탭은 초록 틴트 유리. /// iOS 18 폴백: 머티리얼 블러 원 + 하이라이트 테두리 + 그림자 — 굴절·모핑만 없고 /// 크기·배치·애니메이션은 26과 동일해 사용성 차이가 없다. 현재 탭은 초록 채움 원. /// 26 전용 유리 모디파이어 — 선언을 @available로 격리해 가용성 분기(하위 18 폴백)와 /// glassEffect 제네릭이 뒤섞이지 않게 한다 (하한 18 Release 인라이너 크래시 조사 때 /// 정리한 방어적 형태 — 실제 크래시 원인은 DiaryZoomCoordinator 쪽이었다, §6.7) @available(iOS 26.0, *) private struct LiquidGlassBall: ViewModifier { let tinted: Bool let morphID: String let namespace: Namespace.ID func body(content: Content) -> some View { content .glassEffect( tinted ? .regular.tint(AppTheme.green.opacity(0.85)).interactive() : .regular.interactive(), in: .circle ) .glassEffectID(morphID, in: namespace) } } /// iOS 18 폴백 구슬 — 머티리얼 블러 원 + 하이라이트 테두리 + 그림자 private struct MaterialBall: ViewModifier { let tinted: Bool func body(content: Content) -> some View { content .background { ZStack { Circle().fill(.ultraThinMaterial) if tinted { Circle().fill(AppTheme.green.opacity(0.85)) } Circle().strokeBorder(.white.opacity(0.28), lineWidth: 0.6) } .shadow(color: .black.opacity(0.2), radius: 9, y: 4) } } } private struct LiquidBallBackground: ViewModifier { let tinted: Bool let morphID: String let namespace: Namespace.ID // 분기는 OS로 고정되어 런타임에 바뀌지 않으므로 AnyView 소거가 // 애니메이션·모핑 정체성에 영향을 주지 않는다 func body(content: Content) -> AnyView { if #available(iOS 26.0, *) { return AnyView(content.modifier( LiquidGlassBall(tinted: tinted, morphID: morphID, namespace: namespace) )) } else { return AnyView(content.modifier(MaterialBall(tinted: tinted))) } } }