refactor(ui): redesign floating menu as one-hand glass bubble arc
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
49dd1d0ff2
commit
221dce1259
@ -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
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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: "글래스 버블 메뉴")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user