mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook 5b526fa650 feat(compat): iOS 하한 18.0 인하 — 글라스 버블 머티리얼 폴백·사용자 심볼 안전 렌더·Release 인라이너 크래시 우회
- 배포 타깃 26.0→18.0 (앱·위젯. 워치 10.0 불변)
- RadialNavigationView: glassEffect 계열을 @available(iOS 26) 선언 격리 + 18 머티리얼 원 폴백 (배치·연출 동일)
- DiaryZoomContainer.Coordinator → 비제네릭 톱레벨 DiaryZoomCoordinator + AnyView 소거 (하한 18 Release wholemodule에서 swift-frontend SILPerformanceInliner 무한 재귀 크래시 실측·우회)
- Image(safeSymbol:)/SymbolCompat: 카탈로그의 상위 OS 전용 심볼(18 기준 3개)이 교차 기기에서 빈 아이콘이 되지 않게 사용자 심볼 렌더 48곳+워치 7곳 폴백
- -symbolAuditDump 검증 인자 추가, iOS 18.5 시뮬 QA(빌드·radial 폴백·일기 줌·progressSelfTest 49 ALL PASS)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
2026-08-11 16:31:40 +09:00

406 lines
18 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 {
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)
// + .
// 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)))
}
}
}