From 221dce12592c0c1e1b483fceab2ed388d6b9d36a Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 01:31:45 +0900 Subject: [PATCH] refactor(ui): redesign floating menu as one-hand glass bubble arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vertical capsule stack reached ~414pt up the screen, putting the top tabs out of thumb range. Replace it with 'Glass Bubbles': liquid-glass orbs that fan out from the FAB along a semicircular arc whose radius (~152pt, clamped to screen width) keeps every tab inside the natural thumb sweep from the bottom-center pivot. - Each bubble: ultraThinMaterial circle + top white gradient (specular light) + white 0.4 hairline + soft shadow; label with shadow below. Current tab rendered as green glass with white symbol. - Entrance: bubbles fly from the FAB to their arc slot while rotating -40deg -> 0 with a snappy spring (response 0.32, damping 0.72) and a per-item 0.03s stagger, like droplets bursting in sequence; collapse snaps back with no delay. All transform-only (offset/scale/opacity/ rotation) so no per-frame blur recomputation. - Struct renamed FloatingCapsuleMenu -> GlassBubbleMenu; storage key and NavStyle.radial rawValue unchanged for compatibility. - Settings copy updated: nav style now '글래스 버블 메뉴' with a description of the fan-out and one-handed reach; order editor renamed '버블 메뉴 순서' with left-to-right arc wording. Verified: clean rebuild (stale incremental link produced an old binary despite BUILD SUCCEEDED; wiped DerivedData), expanded-arc screenshot shows all 7 bubbles within thumb radius with legible labels, and the settings screen shows the new name/description. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Views/RadialNavigationView.swift | 180 ++++++++++-------- myApp/HaruDanim/IOS/Views/SettingsView.swift | 16 +- myApp/HaruDanim/Shared/Settings.swift | 6 +- 3 files changed, 107 insertions(+), 95 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift index 5d52f87..74fbbf2 100644 --- a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift +++ b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift @@ -10,11 +10,12 @@ // - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 메뉴 오버레이를 얹는다. // → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다. // -// 메뉴 UI: '독립형 글래스 캡슐(Floating Glass Capsules)'. -// - 거대한 공통 반원 배경을 두지 않고, FAB를 누르면 각 탭이 독립된 알약(Capsule)으로 -// 위쪽으로 통통 튀어 오른다. 각 캡슐은 ultraThinMaterial + 얇은 흰 테두리 + 부드러운 그림자로 -// 유리처럼 떠 있고, 아이콘+텍스트가 넉넉한 패딩 안에 담겨 절대 밖으로 삐져나오지 않는다. -// - 등장 애니메이션은 무거운 블러 트랜지션 없이 offset·scale·opacity + 짧고 탄력 있는 스프링만 사용. +// 메뉴 UI: '글래스 버블(Glass Bubbles)'. +// - FAB를 누르면 리퀴드 글라스 질감의 유리 구슬(버블) 탭 버튼들이 FAB에서 부채꼴 아치로 +// 회전하며 퍼져 나온다. 스태거(순차) 스프링으로 물방울이 튀듯 경쾌하게 등장. +// - 아치 반경이 엄지 반경(~150pt) 안이라 세로 스택과 달리 모든 탭이 한 손에 닿는다. +// - 각 버블: ultraThinMaterial + 상단 화이트 그라데이션(빛 반사) + 얇은 흰 테두리 + 그림자. +// 애니메이션은 offset·scale·opacity·rotation 조합(트랜스폼 전용)이라 블러 재계산이 없어 가볍다. // // iPhone(Compact) 전용 — iPad·Mac은 SidebarRootView, watch는 별도 타깃이라 이 파일과 무관하다. // @@ -29,7 +30,7 @@ struct RadialTabView: View { init() { let router = AppRouter() - // 플로팅 메뉴에는 모든 탭이 노출되므로 '더보기' 우회 라우팅을 쓰지 않는다. + // 버블 메뉴에는 모든 탭이 노출되므로 '더보기' 우회 라우팅을 쓰지 않는다. router.allTabsDirect = true _router = State(initialValue: router) #if DEBUG @@ -54,8 +55,8 @@ struct RadialTabView: View { } } - // 2) 플로팅 캡슐 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. - FloatingCapsuleMenu( + // 2) 글래스 버블 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다. + GlassBubbleMenu( expanded: $menuExpanded, selection: $router.tabSelection ) @@ -64,121 +65,131 @@ struct RadialTabView: View { } } -// MARK: - 플로팅 글래스 캡슐 메뉴 오버레이 +// MARK: - 글래스 버블 메뉴 오버레이 -struct FloatingCapsuleMenu: View { +struct GlassBubbleMenu: 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 bubbleSize: CGFloat = 56 + /// FAB 중심 → 버블 중심 거리. 엄지가 자연스럽게 닿는 반경 안으로 유지한다. + private let desiredRadius: CGFloat = 152 private let bottomInset: CGFloat = 42 - /// FAB 중심 → 첫 캡슐(가장 아래) 중심까지의 거리 - private let firstItemGap: CGFloat = 78 - /// 캡슐 중심 간 세로 간격 - private let itemSpacing: CGFloat = 56 + private let edgePadding: CGFloat = 16 + /// 버블 중심에서 탭 이름 라벨 중심까지의 거리(버블 아래로) + private let labelOffset: CGFloat = 42 - /// 아래에서 위로 쌓이는 순서 (설정의 커스텀 순서를 따른다) + /// 왼쪽 끝 → 위 → 오른쪽 끝 순으로 배치할 탭 (설정의 커스텀 순서를 따른다) private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) } private var currentTab: AppTab { AppTab(rawValue: selection) ?? .main } - /// 펼침: 짧고 탄력 있는(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 { - ZStack { - // 바깥 영역 탭 → 닫기. 펼쳐졌을 때만 화면을 덮어 히트테스트를 받는다. - Color.black - .opacity(expanded ? 0.22 : 0) - .ignoresSafeArea() - .allowsHitTesting(expanded) - .onTapGesture { close() } + GeometryReader { geo in + // 양 끝 버블이 화면 밖으로 잘리지 않도록 반경을 화면 폭에 맞춰 제한 + let radius = min(desiredRadius, geo.size.width / 2 - bubbleSize / 2 - edgePadding) - // FAB(하단 중앙)를 기준점으로 캡슐들이 위쪽으로 펼쳐진다. ZStack { - ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in - capsuleItem(tab, index: index) + // 바깥 영역 탭 → 닫기. 펼쳐졌을 때만 화면을 덮어 히트테스트를 받는다. + Color.black + .opacity(expanded ? 0.22 : 0) + .ignoresSafeArea() + .allowsHitTesting(expanded) + .onTapGesture { close() } + + // FAB(하단 중앙)를 기준점으로 버블들이 부채꼴로 퍼진다. + ZStack { + ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in + bubble(tab, index: index, count: tabs.count, radius: radius) + } + fab } - fab + .frame(width: fabSize, height: fabSize) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, bottomInset) } - .frame(width: fabSize, height: fabSize) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - .padding(.bottom, bottomInset) } .ignoresSafeArea(.keyboard) } - // MARK: 개별 글래스 캡슐 (아이콘 + 텍스트) + // MARK: 유리 구슬 버튼 - /// 각 탭을 독립된 유리 알약으로 렌더링. 배경 뒤가 은은히 비치는 ultraThinMaterial 위에 - /// 얇은 흰 테두리와 부드러운 그림자를 덧대 화면 위에 둥둥 떠 있는 입체감을 준다. - /// 내부 padding이 넉넉해 아이콘/텍스트가 캡슐 밖으로 삐져나오지 않는다. - private func capsuleItem(_ tab: AppTab, index: Int) -> some View { - // index 0(첫 탭)이 FAB에 가장 가깝고, 위로 갈수록 멀어진다. - let restY = -(firstItemGap + CGFloat(index) * itemSpacing) + /// 탭 하나를 리퀴드 글라스 구슬로 렌더링. FAB 위치에서 아치 위 자기 자리로 + /// 회전하며 튀어 나가고, 스태거 딜레이로 물방울이 연달아 튀는 느낌을 준다. + 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: { - // 모든 탭의 내용을 겹쳐 두고 현재 것만 보이게 해 캡슐 폭을 '가장 긴 라벨' 기준으로 - // 통일한다 (언어가 바뀌어도 자동으로 맞춰짐) — 길이가 제각각인 알약들보다 정돈된 리스트. - ZStack(alignment: .leading) { - ForEach(tabs) { t in - capsuleContent(t) - .opacity(t == tab ? 1 : 0) - .accessibilityHidden(t != tab) - } - } - .foregroundStyle(isCurrent ? AppTheme.green : .primary) - .padding(.horizontal, 18) - .padding(.vertical, 12) - .background { + ZStack { + // 유리 구슬 본체 ZStack { - Capsule().fill(.ultraThinMaterial) - // 현재 탭만 은은한 초록 틴트로 강조 (콘텐츠 뒤에 깔림) - Capsule().fill(AppTheme.green.opacity(isCurrent ? 0.16 : 0)) + Circle() + .fill(.ultraThinMaterial) + .overlay( + // 위에서 빛을 받는 유리의 반사광 + Circle().fill( + LinearGradient( + colors: [.white.opacity(0.35), .white.opacity(0.03)], + startPoint: .top, endPoint: .bottom + ) + ) + ) + .overlay( + // 현재 탭은 초록 유리로 강조 + Circle().fill(AppTheme.green.opacity(isCurrent ? 0.82 : 0)) + ) + .overlay(Circle().strokeBorder(.white.opacity(0.4), lineWidth: 1)) + Image(systemName: tab.symbol) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(isCurrent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) } + .frame(width: bubbleSize, height: bubbleSize) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + + // 탭 이름 라벨 — 가독성용 그림자만 (블러 없음) + 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) } - .overlay( - // 빛을 받는 유리 엣지 느낌 - Capsule().stroke(Color.white.opacity(0.4), lineWidth: 1) - ) - .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) + // 펼침: FAB에서 자기 자리로 회전(-40°→0°)하며 날아간다. 접힘: FAB로 모여든다. + // 트랜스폼 전용이라 프레임당 블러 재계산이 없다. + .rotationEffect(.degrees(expanded ? 0 : -40)) + .offset(x: expanded ? dx : 0, y: expanded ? dy : 0) + .scaleEffect(expanded ? 1 : 0.1) .opacity(expanded ? 1 : 0) + // 펼칠 때만 안쪽부터 차례로 튀어나오는 스태거. 닫을 땐 지연 없이 한 번에 스냅. + .animation( + expanded + ? .spring(response: 0.32, dampingFraction: 0.72).delay(Double(index) * 0.03) + : .spring(response: 0.24, dampingFraction: 0.9), + value: expanded + ) // 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다. .allowsHitTesting(expanded) } - /// 캡슐 안 내용물 (아이콘 + 라벨). 폭 통일용 ZStack에서 재사용된다. - private func capsuleContent(_ tab: AppTab) -> some View { - HStack(spacing: 8) { - Image(systemName: tab.symbol) - .font(.system(size: 15, weight: .semibold)) - .frame(width: 20) - Text(tab.label) - .font(.system(size: 15, weight: .semibold)) - .lineLimit(1) - .fixedSize() - } - } - // MARK: 중앙 플로팅 버튼 private var fab: some View { @@ -213,25 +224,26 @@ struct FloatingCapsuleMenu: View { // MARK: 동작 private func toggle() { - withAnimation(expanded ? closeCurve : openCurve) { + // 버블은 자체 per-item 애니메이션을 갖고, 여기서는 배경 딤/FAB 아이콘 전환만 함께 움직인다. + withAnimation(.easeOut(duration: 0.2)) { expanded.toggle() } } private func close() { - withAnimation(closeCurve) { + withAnimation(.easeOut(duration: 0.18)) { expanded = false } } private func select(_ tab: AppTab) { - // 스펙: 아이콘을 누르면 즉시 라우팅하고 메뉴는 닫힌다. + // 스펙: 버블을 누르면 즉시 라우팅하고 메뉴는 닫힌다. selection = tab.rawValue close() } } -/// FAB·캡슐 공통 눌림 효과 (살짝 줄어들며 반응). 스프링 대신 짧은 easeOut으로 가볍게. +/// FAB·버블 공통 눌림 효과 (살짝 줄어들며 반응). 짧은 easeOut으로 가볍게. private struct RadialPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 8a7edf8..35d5460 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -70,20 +70,20 @@ struct SettingsView: View { Text(style.label).tag(style.rawValue) } } - // 캡슐 메뉴일 때만 팝업 순서를 편집할 수 있다 + // 버블 메뉴일 때만 배치 순서를 편집할 수 있다 if navStyleRaw == NavStyle.radial.rawValue { NavigationLink { RadialOrderView() } label: { - Label("캡슐 메뉴 순서 편집", systemImage: "arrow.up.arrow.down") + Label("버블 메뉴 순서 편집", systemImage: "arrow.up.arrow.down") } } } header: { Text("내비게이션") } footer: { - Text("‘플로팅 글래스 캡슐’을 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 알약 형태의 투명한 메뉴가 위로 팝업돼요. (실험적 기능)") + Text("‘글래스 버블 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 유리 구슬 모양의 탭 버튼들이 부채꼴로 퍼져요. 모든 탭이 엄지 반경 안에 있어 한 손으로 조작하기 편해요. (실험적 기능)") } - // 캡슐 메뉴는 모든 탭을 항상 노출하므로 노출 탭 선택이 필요 없다 + // 버블 메뉴는 모든 탭을 항상 노출하므로 노출 탭 선택이 필요 없다 if navStyleRaw == NavStyle.tabBar.rawValue { Section { ForEach(AppTab.allCases) { tab in @@ -287,9 +287,9 @@ struct SettingsView: View { } } -// MARK: - 플로팅 캡슐 메뉴 순서 편집 (iPhone 캡슐 모드 전용) +// MARK: - 글래스 버블 메뉴 순서 편집 (iPhone 버블 모드 전용) -/// 캡슐 메뉴를 펼쳤을 때 아래(버튼 쪽)에서 위로 배치될 탭 순서를 드래그로 바꾼다. +/// 버블 메뉴를 펼쳤을 때 왼쪽 끝 → 위 → 오른쪽 끝으로 배치될 탭 순서를 드래그로 바꾼다. /// 순서는 기기 로컬(비동기화)로만 저장되며, iPad·Mac 사이드바 순서에는 영향을 주지 않는다. struct RadialOrderView: View { @AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = "" @@ -311,13 +311,13 @@ struct RadialOrderView: View { radialOrderRaw = order.map(\.rawValue).joined(separator: ",") } } footer: { - Text("캡슐 메뉴를 펼쳤을 때 아래(버튼과 가까운 쪽)에서 위로 이 순서대로 캡슐이 배치돼요. 손잡이를 끌어 순서를 바꾸세요.") + Text("버블 메뉴를 펼쳤을 때 왼쪽 끝에서 위를 지나 오른쪽 끝으로 이 순서대로 버블이 배치돼요. 손잡이를 끌어 순서를 바꾸세요.") } } .environment(\.editMode, .constant(.active)) .scrollContentBackground(.hidden) .background(AppTheme.background) - .navigationTitle("캡슐 메뉴 순서") + .navigationTitle("버블 메뉴 순서") .navigationBarTitleDisplayMode(.inline) .onAppear { order = AppTab.radialOrder(from: radialOrderRaw) } } diff --git a/myApp/HaruDanim/Shared/Settings.swift b/myApp/HaruDanim/Shared/Settings.swift index a45d647..041bf2e 100644 --- a/myApp/HaruDanim/Shared/Settings.swift +++ b/myApp/HaruDanim/Shared/Settings.swift @@ -29,7 +29,7 @@ enum SettingsKeys { static let goalCardStyle = "settings.goalCardStyle" /// iPhone 내비게이션 방식. NavStyle rawValue ("tabBar" | "radial"). 기기 로컬(비동기화) static let navStyle = "settings.navStyle" - /// 플로팅 캡슐 메뉴에 펼칠 탭 순서. AppTab rawValue를 쉼표로 연결. 기기 로컬(비동기화) + /// 글래스 버블 메뉴에 펼칠 탭 순서. AppTab rawValue를 쉼표로 연결. 기기 로컬(비동기화) static let radialTabOrder = "settings.radialTabOrder" } @@ -37,7 +37,7 @@ enum SettingsKeys { enum NavStyle: String, CaseIterable, Identifiable { /// 기본 하단 탭 바 (노출 탭 + 더보기) case tabBar - /// 플로팅 글래스 캡슐(Floating Glass Capsules) 메뉴 — rawValue는 저장 호환을 위해 radial 유지 + /// 글래스 버블(Glass Bubbles) 메뉴 — rawValue는 저장 호환을 위해 radial 유지 case radial var id: String { rawValue } @@ -45,7 +45,7 @@ enum NavStyle: String, CaseIterable, Identifiable { var label: String { switch self { case .tabBar: return String(localized: "기본 하단 탭 바") - case .radial: return String(localized: "플로팅 글래스 캡슐") + case .radial: return String(localized: "글래스 버블 메뉴") } } }