- 원인: 일기 버블 추가로 이웃 간격 ~9.6pt < 판정 18pt → 정지 상태에서도 이웃끼리 액체 합체가 걸려 원이 늘어남(7개 시절 ~20.7pt는 판정 밖 — '1.3까진 정상' 증언과 일치) - 펼침 상태 정원 분리 시각 QA 확인, FAB 피어남 모핑은 겹침 구간이라 유지 - 마케팅 06-bubble ko/en/ja 재촬영(구 샷에 왜곡 포함)·합성 재생성, 코드 주석에 탭 추가 시 재확인 경고 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
418 lines
20 KiB
Swift
418 lines
20 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) 안이라 세로 스택과 달리 모든 탭이 한 손에 닿는다.
|
||
// - iOS 18 폴백(1.4 하위 OS 지원): glassEffect 계열은 26 전용이라, 18에서는 같은 구슬을
|
||
// 머티리얼 원(블러+하이라이트 테두리+그림자)으로 그린다 — 굴절·액체 모핑만 빠지고
|
||
// 배치·스태거 스프링·더블탭 등 동작은 완전히 동일하다 (LiquidBallBackground 참고).
|
||
//
|
||
// 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 {
|
||
// FAB(하단 42pt + 구슬 64pt)가 항상 떠 있으므로 스크롤 콘텐츠의
|
||
// 끝이 그 위로 올라오게 하단 인셋을 준다 — 없으면 목록 마지막 행이
|
||
// FAB에 가려지고 중앙 탭이 메뉴 열기로 새는 실측 문제(1.4(2) 검수).
|
||
// ⚠️ NavigationStack '안쪽'에 붙일 것 — Tab 콘텐츠(스택 바깥)에
|
||
// 붙이면 TabView가 safe area를 재설정해 무시된다(실측)
|
||
tab.rootView
|
||
.safeAreaInset(edge: .bottom) {
|
||
Color.clear.frame(height: 78)
|
||
}
|
||
}
|
||
.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
|
||
/// 유리가 액체처럼 합쳐지기 시작하는 거리 — 정지 상태의 이웃 버블 간격보다 작아야
|
||
/// 아치에 자리 잡은 뒤 서로 당기지 않고 정원으로 분리된다(FAB에서 피어날 때는 겹치며
|
||
/// 지나가므로 이 값이 작아도 모핑은 유지). ⚠️ 예전 18은 버블 7개(간격 ~20pt) 기준 —
|
||
/// 1.5에서 일기 버블이 추가돼 8개(반지름 152·지름 58이면 간격 ~9.6pt)가 되자 정지
|
||
/// 상태에서도 이웃끼리 합체 판정에 들어가 원이 서로를 향해 늘어나 찌그러졌다
|
||
/// (실기기 실측 1.5(6) 수정 — 탭이 더 늘어도 안전하게 간격 하한보다 작은 6으로)
|
||
private let glassBlendSpacing: CGFloat = 6
|
||
|
||
/// 왼쪽 끝 → 위 → 오른쪽 끝 순으로 배치할 탭 (설정의 커스텀 순서를 따른다)
|
||
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)
|
||
// 모든 유리 요소를 한 컨테이너에 담아 한 패스로 렌더링 + 근접 시 액체 모핑.
|
||
// iOS 18은 컨테이너가 없어 같은 클러스터를 그대로 그린다(모핑만 없음).
|
||
// 26 API 직접 호출 금지 — LiquidGlassBall 주석의 컴파일러 우회 규칙 참고
|
||
if #available(iOS 26.0, *) {
|
||
GlassClusterContainer(spacing: glassBlendSpacing) {
|
||
bubbleCluster(radius: radius)
|
||
}
|
||
} else {
|
||
bubbleCluster(radius: radius)
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
|
||
/// FAB + 버블 클러스터 — 26의 GlassEffectContainer 안팎에서 같은 계층을 쓴다
|
||
private func bubbleCluster(radius: CGFloat) -> some View {
|
||
ZStack {
|
||
if expanded {
|
||
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
||
bubble(tab, index: index, count: tabs.count, radius: radius)
|
||
}
|
||
}
|
||
fab
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
// 진짜 유리(26) 또는 머티리얼 원(18): 현재 탭은 양쪽 모두 초록으로 강조.
|
||
.modifier(LiquidBallBackground(tinted: isCurrent, morphID: tab.rawValue, namespace: 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)
|
||
.modifier(LiquidBallBackground(tinted: false, morphID: "fab", namespace: 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()
|
||
}
|
||
}
|
||
|
||
// MARK: - 구슬 배경 (26 리퀴드 글라스 / 18 머티리얼 폴백)
|
||
|
||
/// 26 전용 컨테이너 래퍼 — LiquidGlassBall과 같은 이유로 선언을 @available로 격리
|
||
@available(iOS 26.0, *)
|
||
private struct GlassClusterContainer<Content: View>: View {
|
||
let spacing: CGFloat
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
GlassEffectContainer(spacing: spacing) { content }
|
||
}
|
||
}
|
||
|
||
/// 유리 구슬 배경 — iOS 26: 시스템 리퀴드 글라스(.regular.interactive — 배경 굴절·빛 반사·
|
||
/// 눌림 시머) + glassEffectID로 FAB와 한 덩어리 모핑. 현재 탭은 초록 틴트 유리.
|
||
/// iOS 18 폴백: 머티리얼 블러 원 + 하이라이트 테두리 + 그림자 — 굴절·모핑만 없고
|
||
/// 크기·배치·애니메이션은 26과 동일해 사용성 차이가 없다. 현재 탭은 초록 채움 원.
|
||
/// 26 전용 유리 모디파이어 — 선언을 @available로 격리해 가용성 분기(하위 18 폴백)와
|
||
/// glassEffect 제네릭이 뒤섞이지 않게 한다 (하한 18 Release 인라이너 크래시 조사 때
|
||
/// 정리한 방어적 형태 — 실제 크래시 원인은 DiaryZoomCoordinator 쪽이었다, §6.7)
|
||
@available(iOS 26.0, *)
|
||
private struct LiquidGlassBall: ViewModifier {
|
||
let tinted: Bool
|
||
let morphID: String
|
||
let namespace: Namespace.ID
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.glassEffect(
|
||
tinted
|
||
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
|
||
: .regular.interactive(),
|
||
in: .circle
|
||
)
|
||
.glassEffectID(morphID, in: namespace)
|
||
}
|
||
}
|
||
|
||
/// iOS 18 폴백 구슬 — 머티리얼 블러 원 + 하이라이트 테두리 + 그림자
|
||
private struct MaterialBall: ViewModifier {
|
||
let tinted: Bool
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background {
|
||
ZStack {
|
||
Circle().fill(.ultraThinMaterial)
|
||
if tinted {
|
||
Circle().fill(AppTheme.green.opacity(0.85))
|
||
}
|
||
Circle().strokeBorder(.white.opacity(0.28), lineWidth: 0.6)
|
||
}
|
||
.shadow(color: .black.opacity(0.2), radius: 9, y: 4)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct LiquidBallBackground: ViewModifier {
|
||
let tinted: Bool
|
||
let morphID: String
|
||
let namespace: Namespace.ID
|
||
|
||
// 분기는 OS로 고정되어 런타임에 바뀌지 않으므로 AnyView 소거가
|
||
// 애니메이션·모핑 정체성에 영향을 주지 않는다
|
||
func body(content: Content) -> AnyView {
|
||
if #available(iOS 26.0, *) {
|
||
return AnyView(content.modifier(
|
||
LiquidGlassBall(tinted: tinted, morphID: morphID, namespace: namespace)
|
||
))
|
||
} else {
|
||
return AnyView(content.modifier(MaterialBall(tinted: tinted)))
|
||
}
|
||
}
|
||
}
|