mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook 1b36607ba1 feat(radial): rebuild glass bubble menu on native Liquid Glass
글래스 버블 메뉴를 시스템 리퀴드 글라스(glassEffect) 기반으로 재구축.
컨셉(FAB → 부채꼴 유리 구슬, 엄지 반경 ~150pt, 바깥 탭 닫기, 순서 설정)은
그대로 두고 유리 질감·연출·성능을 올렸다.

유리: material 흉내(ultraThinMaterial + 그라데이션 + 테두리) 대신
.glassEffect(.regular.interactive()) — 배경 굴절·빛 반사·눌림 시머까지
시스템이 그린다. 현재 탭은 초록 틴트 유리.

연출: 2단계 — 삽입 순간 FAB 유리에서 모핑으로 부풀어 나온 뒤(bloomed)
스태거 바운스 스프링으로 아치에 흩어진다. GlassEffectContainer 근접
블렌딩으로 FAB를 벗어날 때 유리가 늘어나다 물방울처럼 분리. 닫힘은
지연 없이 흡수. 그라데이션 딤 + 메뉴 뒤 초록 빛 무리 + 햅틱.

성능(버벅임): 예전엔 블러 14장(버블 7 + 라벨 알약 7) + 그림자 14개가
각각 애니메이션됐다. 라벨을 구슬 안에 넣어 유리 요소를 8개로 줄이고
GlassEffectContainer가 한 패스로 일괄 렌더링, 커스텀 그림자 제거.

주의점 기록: 컨테이너 안 유리 요소는 glassEffect 뒤 opacity/scale이
콘텐츠에 온전히 적용되지 않아(접힌 상태 내용물 비침) 숨김은 계층
제거(if expanded), 이동은 offset으로만 처리.

검증: 접힘·펼침(라이트/다크) 스크린샷으로 유리 굴절·라벨 가독성·
콘텐츠 누출 없음 확인. Debug·Store 스킴 빌드 성공.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 20:16:25 +09:00

288 lines
13 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 = ""
/// 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 }
}
}
// 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 { close() } else { 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()
}
}