글래스 버블 메뉴가 닫힌 상태에서 FAB를 0.35초 안에 빠르게 두 번 누르면 설정한 탭으로 바로 이동하는 옵션. 첫 탭을 지연시키는 더블탭 제스처 인식은 메뉴 열림 반응성을 해치므로, 첫 탭은 즉시 메뉴를 열고 두 번째 탭에서만 분기한다 — 더블탭 시 버블이 피어나다 FAB로 흡수되며 화면이 바뀌는 연출. - 설정(내비게이션, 버블 모드 전용): '두 번 눌러 바로 이동' 토글 + '이동할 탭' 피커 + 푸터 설명. settings.radialDoubleTapTab (rawValue, 빈 값=꺼짐, 기기 로컬) - 검증용 런치 인자 -radialDoubleTapTest (2초 뒤 더블탭 재현) — 통계 탭 이동 스크린샷으로 확인 - 도움말: 버블 메뉴 항목에 더블탭 문장 추가 + 누락돼 있던 '홈 화면·잠금화면 위젯' 사용법 항목 신설 - l10n 6개 문자열 ko/en/ja, CLAUDE.md §6·§6.8·§14 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
325 lines
15 KiB
Swift
325 lines
15 KiB
Swift
//
|
|
// 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) 안이라 세로 스택과 달리 모든 탭이 한 손에 닿는다.
|
|
//
|
|
// 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 {
|
|
tab.rootView
|
|
}
|
|
.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)
|
|
// 모든 유리 요소를 한 컨테이너에 담아 한 패스로 렌더링 + 근접 시 액체 모핑
|
|
GlassEffectContainer(spacing: glassBlendSpacing) {
|
|
ZStack {
|
|
if expanded {
|
|
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
|
bubble(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)
|
|
.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)
|
|
}
|
|
|
|
// 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)
|
|
// 진짜 유리: 배경 굴절·빛 반사에 더해 .interactive가 눌림 시머·바운스까지 그려 준다.
|
|
// 현재 탭은 초록 틴트 유리로 강조.
|
|
.glassEffect(
|
|
isCurrent
|
|
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
|
|
: .regular.interactive(),
|
|
in: .circle
|
|
)
|
|
.glassEffectID(tab.rawValue, in: 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)
|
|
.glassEffect(.regular.interactive(), in: .circle)
|
|
.glassEffectID("fab", in: 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()
|
|
}
|
|
}
|