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
This commit is contained in:
songyc macbook 2026-07-13 20:16:25 +09:00
parent a9473be937
commit 1b36607ba1
2 changed files with 139 additions and 110 deletions

View File

@ -140,7 +140,7 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
- **스플래시**: 앱 로고(AppLogo) + 이름 + 멘트, 1.2초 후 페이드아웃 (`ContentView`)
- **탭 정의** (`AppTab`): 모음(main) · 행동 · 꼬리표 · 목표 · 기록 · 통계 · 일기(iPad 전용) · 설정
- **iPhone 기본**: 하단 탭바에 **노출 탭 1~3개**(기본 모음·행동·기록) + "더보기" 탭(나머지 목록). 설정에서 구성
- **iPhone 실험 옵션**: "글래스 버블 메뉴"(`RadialNavigationView`) — 하단 중앙 FAB를 누르면 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 라벨은 글라스 알약 배경. 순서는 설정에서 편집(기기 로컬)
- **iPhone 실험 옵션**: "글래스 버블 메뉴"(`RadialNavigationView`) — 하단 중앙 FAB를 누르면 리퀴드 글라스(`glassEffect(.regular.interactive())`) 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 아이콘+이름이 구슬 안에 함께. 모든 유리 요소는 하나의 `GlassEffectContainer`에서 한 패스로 렌더링(성능)되고 FAB 근처에서 액체처럼 분리·합체 모핑. 2단계 연출: 삽입(FAB에서 모핑) → bloomed(스태거 스프링으로 아치 확산). ⚠️ 컨테이너 안 유리 요소에는 glassEffect 뒤 opacity/scale이 콘텐츠에 온전히 적용되지 않음 — 숨김은 계층 제거(`if`), 이동은 `offset`으로만. 순서는 설정에서 편집(기기 로컬)
- **iPad·Mac**: 항상 `NavigationSplitView` 사이드바 (`SidebarRootView`, `DeviceLayout.isPad`로 판정)
- `AppRouter`: 탭 전환 + 탭 간 전달값(모음 탭 롱프레스 → 기록/통계 탭 행동 필터 예약, 더보기 내부 경로 진입 포함)

View File

@ -10,12 +10,17 @@
// - (ZStack ) .
// FAB/ · .
//
// UI: ' (Glass Bubbles)'.
// - FAB () FAB
// . () .
// UI: ' (Glass Bubbles)' (glassEffect) .
// - FAB (.regular.interactive · ·
// ) FAB .
// - GlassEffectContainer :
// material 14( 7 + 7) + 14
// . 8( 7 + FAB) ,
// . FAB
// · .
// - : () + . : FAB .
// ( ) .
// - (~150pt) .
// - : ultraThinMaterial + ( ) + + .
// offset·scale·opacity·rotation ( ) .
//
// iPhone(Compact) iPad·Mac SidebarRootView, watch .
//
@ -75,15 +80,26 @@ struct GlassBubbleMenu: View {
/// ( , )
@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 = 62
private let bubbleSize: CGFloat = 56
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 = 16
/// ( )
private let labelOffset: CGFloat = 42
private let edgePadding: CGFloat = 14
/// . (~20pt)
/// , FAB .
private let glassBlendSpacing: CGFloat = 18
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
@ -98,19 +114,22 @@ struct GlassBubbleMenu: View {
let radius = min(desiredRadius, geo.size.width / 2 - bubbleSize / 2 - edgePadding)
ZStack {
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
dimLayer
// FAB( ) .
// FAB( ) .
ZStack {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
bubble(tab, index: index, count: tabs.count, radius: radius)
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
}
}
fab
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
@ -118,12 +137,50 @@ struct GlassBubbleMenu: View {
}
}
.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
/// , .
///
/// .
/// : 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
@ -135,64 +192,42 @@ struct GlassBubbleMenu: View {
return Button {
select(tab)
} label: {
ZStack {
//
ZStack {
Circle()
.fill(.ultraThinMaterial)
.overlay(
//
Circle().fill(
LinearGradient(
colors: [.white.opacity(0.35), .white.opacity(0.03)],
startPoint: .top, endPoint: .bottom
)
)
)
.overlay(
//
Circle().fill(AppTheme.green.opacity(isCurrent ? 0.82 : 0))
)
.overlay(Circle().strokeBorder(.white.opacity(0.4), lineWidth: 1))
Image(systemName: tab.symbol)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isCurrent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
}
.frame(width: bubbleSize, height: bubbleSize)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
//
// ( )
VStack(spacing: 2) {
Image(systemName: tab.symbol)
.font(.system(size: 19, weight: .semibold))
Text(tab.label)
.font(.system(size: 11, weight: .semibold))
.font(.system(size: 9.5, weight: .semibold))
.lineLimit(1)
.fixedSize()
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
.padding(.horizontal, 9)
.padding(.vertical, 3.5)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.35), lineWidth: 0.8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.offset(y: labelOffset)
.minimumScaleFactor(0.75)
}
.foregroundStyle(isCurrent ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
// FAB
// (glassEffect '' )
.opacity(bloomed ? 1 : 0)
.frame(width: bubbleSize, height: bubbleSize)
.contentShape(Circle())
}
.buttonStyle(RadialPressStyle())
.accessibilityLabel(Text(tab.label))
// : FAB (-40°0°) . : FAB .
// .
.rotationEffect(.degrees(expanded ? 0 : -40))
.offset(x: expanded ? dx : 0, y: expanded ? dy : 0)
.scaleEffect(expanded ? 1 : 0.1)
.opacity(expanded ? 1 : 0)
// . .
.animation(
expanded
? .spring(response: 0.32, dampingFraction: 0.72).delay(Double(index) * 0.03)
: .spring(response: 0.24, dampingFraction: 0.9),
value: expanded
.buttonStyle(.plain)
// : · .interactive · .
// .
.glassEffect(
isCurrent
? .regular.tint(AppTheme.green.opacity(0.85)).interactive()
: .regular.interactive(),
in: .circle
)
// FAB .
.allowsHitTesting(expanded)
.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:
@ -201,42 +236,45 @@ struct GlassBubbleMenu: View {
Button {
toggle()
} label: {
ZStack {
Circle()
.fill(.ultraThinMaterial)
.overlay(
Circle().fill(
LinearGradient(
colors: [.white.opacity(0.28), .white.opacity(0.02)],
startPoint: .top, endPoint: .bottom
)
)
)
.overlay(Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14)))
.overlay(Circle().strokeBorder(.white.opacity(0.4), lineWidth: 1))
Image(systemName: expanded ? "xmark" : currentTab.symbol)
.font(.system(size: expanded ? 22 : 24, weight: .semibold))
.foregroundStyle(AppTheme.green)
.contentTransition(.symbolEffect(.replace))
}
.frame(width: fabSize, height: fabSize)
.shadow(color: .black.opacity(0.22), radius: 10, y: 4)
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(RadialPressStyle())
.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() {
// per-item , /FAB .
withAnimation(.easeOut(duration: 0.2)) {
expanded.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(.easeOut(duration: 0.18)) {
withAnimation(.spring(response: 0.26, dampingFraction: 0.9)) {
bloomed = false
expanded = false
}
}
@ -247,12 +285,3 @@ struct GlassBubbleMenu: View {
close()
}
}
/// FAB· ( ). easeOut .
private struct RadialPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
}
}