mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook 973bb50cba feat(radial): double-tap the closed FAB to jump to a chosen tab
글래스 버블 메뉴가 닫힌 상태에서 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
2026-07-14 02:54:14 +09:00

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()
}
}