From bd6069741973369dba647516c45d8b0bd20aaa86 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 00:06:55 +0900 Subject: [PATCH] refactor(ui): redesign floating menu to detached glass capsules for better aesthetics and performance Replace the single semicircle dome + arc-arranged icons (which distorted and pushed labels outside the fan) with independent floating glass capsules that pop up in a vertical stack from the FAB. - Each tab is now a self-sizing Capsule: icon + label with generous padding so text can never overflow; ultraThinMaterial background, thin white translucent border (.stroke white 0.4), and a soft drop shadow for a floating glass look. - Current tab gets a subtle green tint behind the content. - Removed the heavy DomeShape glass fan and its scale/opacity blur transition plus all per-item arc math; appearance is driven purely by offset + scale + opacity under one snappy spring (response 0.3, damping 0.7), so no per-item blur passes and no progressive frame drops. - Renamed RadialMenu -> FloatingCapsuleMenu; RadialTabView / settings keys / nav-style routing unchanged. Verified: BUILD SUCCEEDED (iPhone 17 Pro sim) and expanded-state screenshot confirms all 7 capsules render legibly with text fully inside each pill, no distortion, current tab highlighted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Views/RadialNavigationView.swift | 216 +++++++----------- 1 file changed, 82 insertions(+), 134 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift index ee37967..a2faee7 100644 --- a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift +++ b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift @@ -2,14 +2,20 @@ // RadialNavigationView.swift // Haru_Danim // -// [실험적] iPhone 전용 플로팅 반원(Radial) 내비게이션 (설정 → 내비게이션 방식에서 전환). +// [실험적] iPhone 전용 플로팅 내비게이션 (설정 → 내비게이션 방식에서 전환). // // 구조: // - 하단 시스템 탭바를 완전히 숨긴 TabView로 7개 탭의 화면·내비게이션 상태를 그대로 보존하고 // (탭 전환은 중앙 FAB 메뉴가 담당), -// - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 반원 메뉴 오버레이를 얹는다. +// - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 메뉴 오버레이를 얹는다. // → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다. // +// 메뉴 UI: '독립형 글래스 캡슐(Floating Glass Capsules)'. +// - 거대한 공통 반원 배경을 두지 않고, FAB를 누르면 각 탭이 독립된 알약(Capsule)으로 +// 위쪽으로 통통 튀어 오른다. 각 캡슐은 ultraThinMaterial + 얇은 흰 테두리 + 부드러운 그림자로 +// 유리처럼 떠 있고, 아이콘+텍스트가 넉넉한 패딩 안에 담겨 절대 밖으로 삐져나오지 않는다. +// - 등장 애니메이션은 무거운 블러 트랜지션 없이 offset·scale·opacity + 짧고 탄력 있는 스프링만 사용. +// // iPhone(Compact) 전용 — iPad·Mac은 SidebarRootView, watch는 별도 타깃이라 이 파일과 무관하다. // @@ -23,7 +29,7 @@ struct RadialTabView: View { init() { let router = AppRouter() - // 반원 메뉴에는 모든 탭이 노출되므로 '더보기' 우회 라우팅을 쓰지 않는다. + // 플로팅 메뉴에는 모든 탭이 노출되므로 '더보기' 우회 라우팅을 쓰지 않는다. router.allTabsDirect = true _router = State(initialValue: router) #if DEBUG @@ -48,8 +54,8 @@ struct RadialTabView: View { } } - // 2) 반원 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. - RadialMenu( + // 2) 플로팅 캡슐 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. + FloatingCapsuleMenu( expanded: $menuExpanded, selection: $router.tabSelection ) @@ -58,94 +64,105 @@ struct RadialTabView: View { } } -// MARK: - 반원 메뉴 오버레이 +// MARK: - 플로팅 글래스 캡슐 메뉴 오버레이 -struct RadialMenu: View { +struct FloatingCapsuleMenu: 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 + /// FAB 중심 → 첫 캡슐(가장 아래) 중심까지의 거리 + private let firstItemGap: CGFloat = 78 + /// 캡슐 중심 간 세로 간격 + private let itemSpacing: CGFloat = 56 - /// 왼쪽 끝 → 위 → 오른쪽 끝 순으로 배치할 탭 (설정의 커스텀 순서를 따른다) + /// 아래에서 위로 쌓이는 순서 (설정의 커스텀 순서를 따른다) private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) } private var currentTab: AppTab { AppTab(rawValue: selection) ?? .main } - /// 열고/닫는 전환. 무거운 스프링 대신 짧고 빠른 easeOut으로 프레임 드랍을 줄인다. - private var openCurve: Animation { .easeOut(duration: 0.24) } - private var closeCurve: Animation { .easeIn(duration: 0.18) } + /// 펼침: 짧고 탄력 있는(snappy) 스프링 — 통통 튀어 오르는 느낌. + private var openCurve: Animation { .spring(response: 0.3, dampingFraction: 0.7, blendDuration: 0) } + /// 접힘: 되튐 없이 빠르게 사라지도록 살짝 더 단단한 스프링. + private var closeCurve: Animation { .spring(response: 0.25, dampingFraction: 0.9, blendDuration: 0) } 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 { - // 바깥 영역 탭 → 닫기. 펼쳐졌을 때만 화면을 덮어 히트테스트를 받는다. - Color.black - .opacity(expanded ? 0.22 : 0) - .ignoresSafeArea() - .allowsHitTesting(expanded) - .onTapGesture { close() } - - // FAB(하단 중앙)를 기준점으로 아이콘들이 위쪽 반원으로 펼쳐진다. - ZStack { - // 부채꼴 전체를 덮는 '단 하나의' 유리 배경 — 블러 연산을 한 번만 수행한다. - // (예전엔 아이콘·라벨마다 ultraThinMaterial을 따로 씌워 15개 가까운 블러가 - // 스크롤 중 매 프레임 재계산돼 버벅임의 주원인이었다) - if expanded { - glassFan(radius: radius) - .transition(.scale(scale: 0.4, anchor: .bottom).combined(with: .opacity)) - } - ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in - arcItem(tab, index: index, count: tabs.count, radius: radius) - } - fab + ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in + capsuleItem(tab, index: index) } - .frame(width: fabSize, height: fabSize) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .padding(.bottom, bottomInset) + fab } + .frame(width: fabSize, height: fabSize) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, bottomInset) } .ignoresSafeArea(.keyboard) } - // MARK: 통합 유리 배경 (부채꼴 돔) + // MARK: 개별 글래스 캡슐 (아이콘 + 텍스트) - /// 아이콘들이 얹히는 반원 돔. 유리(ultraThinMaterial) 위에 은은한 화이트 그라데이션 + - /// 얇은 반투명 흰 테두리 + 약한 그림자를 덧대 고급스러운 글라스 질감을 만든다. 블러는 여기 한 번만. - private func glassFan(radius: CGFloat) -> some View { - let r = radius + itemSize / 2 + 12 // 아이콘 원까지 넉넉히 감싸는 반경 - let skirt = itemSize / 2 + 8 // 수평(양 끝) 아이콘까지 덮도록 아래로 살짝 연장 - return DomeShape(skirt: skirt) - .fill(.ultraThinMaterial) + /// 각 탭을 독립된 유리 알약으로 렌더링. 배경 뒤가 은은히 비치는 ultraThinMaterial 위에 + /// 얇은 흰 테두리와 부드러운 그림자를 덧대 화면 위에 둥둥 떠 있는 입체감을 준다. + /// 내부 padding이 넉넉해 아이콘/텍스트가 캡슐 밖으로 삐져나오지 않는다. + private func capsuleItem(_ tab: AppTab, index: Int) -> some View { + // index 0(첫 탭)이 FAB에 가장 가깝고, 위로 갈수록 멀어진다. + let restY = -(firstItemGap + CGFloat(index) * itemSpacing) + let isCurrent = tab == currentTab + + return Button { + select(tab) + } label: { + HStack(spacing: 8) { + Image(systemName: tab.symbol) + .font(.system(size: 15, weight: .semibold)) + Text(tab.label) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .fixedSize() + } + .foregroundStyle(isCurrent ? AppTheme.green : .primary) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background { + ZStack { + Capsule().fill(.ultraThinMaterial) + // 현재 탭만 은은한 초록 틴트로 강조 (콘텐츠 뒤에 깔림) + Capsule().fill(AppTheme.green.opacity(isCurrent ? 0.16 : 0)) + } + } .overlay( - DomeShape(skirt: skirt).fill( - LinearGradient( - colors: [.white.opacity(0.30), .white.opacity(0.05)], - startPoint: .top, endPoint: .bottom - ) - ) + // 빛을 받는 유리 엣지 느낌 + Capsule().stroke(Color.white.opacity(0.4), lineWidth: 1) ) - .overlay(DomeShape(skirt: skirt).stroke(.white.opacity(0.2), lineWidth: 1)) - .frame(width: 2 * r, height: r + skirt) - // 돔의 지름선을 FAB 중심 높이에 맞춰 위쪽 반원이 아이콘들을 감싸게 한다. - .offset(y: -(r - skirt) / 2) - .shadow(color: .black.opacity(0.18), radius: 14, y: 6) - .allowsHitTesting(false) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + } + .buttonStyle(RadialPressStyle()) + .accessibilityLabel(Text(tab.label)) + // 펼침: FAB 위치(offset 0, 작게)에서 각자 자리로 튀어 오르며 나타난다. + // 접힘: 다시 FAB로 모여들며 사라진다. + .offset(y: expanded ? restY : 0) + .scaleEffect(expanded ? 1 : 0.3, anchor: .bottom) + .opacity(expanded ? 1 : 0) + // 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다. + .allowsHitTesting(expanded) } // MARK: 중앙 플로팅 버튼 @@ -166,7 +183,7 @@ struct RadialMenu: View { ) ) .overlay(Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14))) - .overlay(Circle().strokeBorder(.white.opacity(0.22), lineWidth: 1)) + .overlay(Circle().strokeBorder(.white.opacity(0.4), lineWidth: 1)) Image(systemName: expanded ? "xmark" : currentTab.symbol) .font(.system(size: expanded ? 22 : 24, weight: .semibold)) .foregroundStyle(AppTheme.green) @@ -179,54 +196,6 @@ struct RadialMenu: View { .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 { - // 아이콘 — 재질 블러 없음. 현재 탭만 초록 틴트 원으로 강조(가벼운 단색 채움). - ZStack { - if isCurrent { - Circle() - .fill(AppTheme.green.opacity(0.9)) - .overlay(Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)) - } - Image(systemName: tab.symbol) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(isCurrent ? .white : .primary) - } - .frame(width: itemSize, height: itemSize) - - // 탭 이름 라벨 — 캡슐 블러 없이 텍스트 + 가독성용 그림자만 - Text(tab.label) - .font(.system(size: 11, weight: .semibold)) - .lineLimit(1) - .fixedSize() - .foregroundStyle(isCurrent ? AppTheme.green : .primary) - .shadow(color: .black.opacity(0.15), radius: 2, y: 1) - .offset(y: labelOffset) - } - } - .buttonStyle(RadialPressStyle()) - .accessibilityLabel(Text(tab.label)) - // 펼쳐질 때 중앙(FAB)에서 각 위치로 퍼지고, 접힐 땐 중앙으로 모인다. - // (per-item 암시적 애니메이션·지연 제거 → 바깥 withAnimation 하나로 함께 움직여 가볍다) - .offset(x: expanded ? dx : 0, y: expanded ? dy : 0) - .scaleEffect(expanded ? 1 : 0.3) - .opacity(expanded ? 1 : 0) - // 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다. - .allowsHitTesting(expanded) - } - // MARK: 동작 private func toggle() { @@ -248,28 +217,7 @@ struct RadialMenu: View { } } -/// 위쪽 반원(돔) + 아래 짧은 스커트. 부채꼴 아이콘들을 하나의 유리 면으로 감싸는 배경 모양. -private struct DomeShape: Shape { - /// 지름선 아래로 연장하는 높이 (수평 위치 아이콘까지 덮기 위함) - var skirt: CGFloat = 0 - - func path(in rect: CGRect) -> Path { - var p = Path() - let r = rect.width / 2 - let cx = rect.midX - let baseY = rect.maxY - skirt - p.addArc( - center: CGPoint(x: cx, y: baseY), radius: r, - startAngle: .degrees(180), endAngle: .degrees(360), clockwise: false - ) - p.addLine(to: CGPoint(x: cx + r, y: rect.maxY)) - p.addLine(to: CGPoint(x: cx - r, y: rect.maxY)) - p.closeSubpath() - return p - } -} - -/// FAB·아이콘 공통 눌림 효과 (살짝 줄어들며 반응). 스프링 대신 짧은 easeOut으로 가볍게. +/// FAB·캡슐 공통 눌림 효과 (살짝 줄어들며 반응). 스프링 대신 짧은 easeOut으로 가볍게. private struct RadialPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label