mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook ac68fddb14 fix(1.5-p8): 버블 메뉴 원 찌그러짐 — 8개 배치에서 유리 합체 판정 거리 초과(glassBlendSpacing 18→6), 빌드 6
- 원인: 일기 버블 추가로 이웃 간격 ~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
2026-08-23 07:08:39 +09:00

418 lines
20 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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