// // RadialNavigationView.swift // Haru_Danim // // [실험적] iPhone 전용 플로팅 반원(Radial) 내비게이션 (설정 → 내비게이션 방식에서 전환). // // 구조: // - 하단 시스템 탭바를 완전히 숨긴 TabView로 7개 탭의 화면·내비게이션 상태를 그대로 보존하고 // (탭 전환은 중앙 FAB 메뉴가 담당), // - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 반원 메뉴 오버레이를 얹는다. // → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다. // // 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.allCases) { tab in Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) { NavigationStack { tab.rootView } .toolbar(.hidden, for: .tabBar) } } } // 2) 반원 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. RadialMenu( expanded: $menuExpanded, selection: $router.tabSelection ) } .environment(router) } } // MARK: - 반원 메뉴 오버레이 struct RadialMenu: View { @Binding var expanded: Bool /// AppTab.rawValue (라우터의 tabSelection과 직접 연결) @Binding var selection: String /// 사용자가 설정에서 지정한 반원 메뉴 아이콘 순서 (기기 로컬, 비동기화) @AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = "" // 레이아웃 상수 private let fabSize: CGFloat = 62 private let itemSize: CGFloat = 52 private let desiredRadius: CGFloat = 150 private let bottomInset: CGFloat = 42 private let edgePadding: CGFloat = 18 /// 아이콘 원 중심에서 탭 이름 라벨 중심까지의 거리(원 아래로) private let labelOffset: CGFloat = 40 /// 왼쪽 끝 → 위 → 오른쪽 끝 순으로 배치할 탭 (설정의 커스텀 순서를 따른다) 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 - itemSize / 2 - edgePadding) ZStack { // 바깥 영역 탭 → 닫기. 펼쳐졌을 때만 화면을 덮어 히트테스트를 받는다. Color.black .opacity(expanded ? 0.22 : 0) .ignoresSafeArea() .allowsHitTesting(expanded) .onTapGesture { close() } // FAB(하단 중앙)를 기준점으로 아이콘들이 위쪽 반원으로 펼쳐진다. ZStack { ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in arcItem(tab, index: index, count: tabs.count, radius: radius) } fab } .frame(width: fabSize, height: fabSize) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .padding(.bottom, bottomInset) } } .ignoresSafeArea(.keyboard) } // MARK: 중앙 플로팅 버튼 private var fab: some View { Button { toggle() } label: { ZStack { Circle() .fill(.ultraThinMaterial) .overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1)) .overlay( Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14)) ) Image(systemName: expanded ? "xmark" : currentTab.symbol) .font(.system(size: expanded ? 22 : 24, weight: .semibold)) .foregroundStyle(AppTheme.green) .rotationEffect(.degrees(expanded ? 90 : 0)) .contentTransition(.symbolEffect(.replace)) } .frame(width: fabSize, height: fabSize) .shadow(color: .black.opacity(0.22), radius: 10, y: 4) } .buttonStyle(RadialPressStyle()) .accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기")) } // MARK: 반원 위 탭 아이콘 private func arcItem(_ 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: { // 바깥 ZStack의 레이아웃 프레임 = 아이콘 원(itemSize)뿐이므로, // 라벨을 offset으로 아래에 띄워도 아이콘 중심은 반원 위 위치를 그대로 유지한다. ZStack { // 아이콘 원 ZStack { Circle() .fill(.ultraThinMaterial) .overlay( Circle().fill(AppTheme.green.opacity(isCurrent ? 0.22 : 0)) ) .overlay( Circle().strokeBorder( isCurrent ? AppTheme.green.opacity(0.9) : .white.opacity(0.16), lineWidth: isCurrent ? 2 : 1 ) ) Image(systemName: tab.symbol) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(isCurrent ? AppTheme.green : .primary) } .frame(width: itemSize, height: itemSize) .shadow(color: .black.opacity(0.18), radius: 6, y: 3) // 탭 이름 라벨 (글라스 캡슐, 원 아래로) Text(tab.label) .font(.system(size: 11, weight: .semibold)) .lineLimit(1) .fixedSize() .foregroundStyle(isCurrent ? AppTheme.green : .primary) .padding(.horizontal, 7) .padding(.vertical, 2.5) .background(Capsule().fill(.ultraThinMaterial)) .overlay(Capsule().strokeBorder(.white.opacity(0.14), lineWidth: 0.5)) .shadow(color: .black.opacity(0.12), radius: 3, y: 1) .offset(y: labelOffset) } } .buttonStyle(RadialPressStyle()) .accessibilityLabel(Text(tab.label)) // 펼쳐질 때 중앙(FAB)에서 각 위치로 피어나고, 접힐 땐 중앙으로 모인다. .offset(x: expanded ? dx : 0, y: expanded ? dy : 0) .scaleEffect(expanded ? 1 : 0.3) .opacity(expanded ? 1 : 0) .animation( .spring(response: 0.42, dampingFraction: 0.72) .delay(expanded ? Double(index) * 0.035 : 0), value: expanded ) // 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다. .allowsHitTesting(expanded) } // MARK: 동작 private func toggle() { withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) { expanded.toggle() } } private func close() { withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) { expanded = false } } private func select(_ tab: AppTab) { // 스펙: 아이콘을 누르면 즉시 라우팅하고 메뉴는 닫힌다. selection = tab.rawValue close() } } /// FAB·아이콘 공통 눌림 효과 (살짝 줄어들며 반응) private struct RadialPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.9 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed) } }