refactor(ui): redesign floating menu as one-hand glass bubble arc

The vertical capsule stack reached ~414pt up the screen, putting the top
tabs out of thumb range. Replace it with 'Glass Bubbles': liquid-glass
orbs that fan out from the FAB along a semicircular arc whose radius
(~152pt, clamped to screen width) keeps every tab inside the natural
thumb sweep from the bottom-center pivot.

- Each bubble: ultraThinMaterial circle + top white gradient (specular
  light) + white 0.4 hairline + soft shadow; label with shadow below.
  Current tab rendered as green glass with white symbol.
- Entrance: bubbles fly from the FAB to their arc slot while rotating
  -40deg -> 0 with a snappy spring (response 0.32, damping 0.72) and a
  per-item 0.03s stagger, like droplets bursting in sequence; collapse
  snaps back with no delay. All transform-only (offset/scale/opacity/
  rotation) so no per-frame blur recomputation.
- Struct renamed FloatingCapsuleMenu -> GlassBubbleMenu; storage key and
  NavStyle.radial rawValue unchanged for compatibility.
- Settings copy updated: nav style now '글래스 버블 메뉴' with a
  description of the fan-out and one-handed reach; order editor renamed
  '버블 메뉴 순서' with left-to-right arc wording.

Verified: clean rebuild (stale incremental link produced an old binary
despite BUILD SUCCEEDED; wiped DerivedData), expanded-arc screenshot
shows all 7 bubbles within thumb radius with legible labels, and the
settings screen shows the new name/description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 01:31:45 +09:00
parent 49dd1d0ff2
commit 221dce1259
3 changed files with 107 additions and 95 deletions

View File

@ -10,11 +10,12 @@
// - (ZStack ) .
// FAB/ · .
//
// UI: ' (Floating Glass Capsules)'.
// - , FAB (Capsule)
// . ultraThinMaterial + +
// , + .
// - offset·scale·opacity + .
// UI: ' (Glass Bubbles)'.
// - FAB () FAB
// . () .
// - (~150pt) .
// - : ultraThinMaterial + ( ) + + .
// offset·scale·opacity·rotation ( ) .
//
// iPhone(Compact) iPad·Mac SidebarRootView, watch .
//
@ -29,7 +30,7 @@ struct RadialTabView: View {
init() {
let router = AppRouter()
// '' .
// '' .
router.allTabsDirect = true
_router = State(initialValue: router)
#if DEBUG
@ -54,8 +55,8 @@ struct RadialTabView: View {
}
}
// 2) . collapsed FAB .
FloatingCapsuleMenu(
// 2) . collapsed FAB .
GlassBubbleMenu(
expanded: $menuExpanded,
selection: $router.tabSelection
)
@ -64,121 +65,131 @@ struct RadialTabView: View {
}
}
// MARK: -
// MARK: -
struct FloatingCapsuleMenu: View {
struct GlassBubbleMenu: View {
@Binding var expanded: Bool
/// AppTab.rawValue ( tabSelection )
@Binding var selection: String
/// ( , )
/// ( , )
@AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = ""
//
private let fabSize: CGFloat = 62
private let bubbleSize: CGFloat = 56
/// FAB . .
private let desiredRadius: CGFloat = 152
private let bottomInset: CGFloat = 42
/// FAB ( )
private let firstItemGap: CGFloat = 78
///
private let itemSpacing: CGFloat = 56
private let edgePadding: CGFloat = 16
/// ( )
private let labelOffset: CGFloat = 42
/// ( )
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
private var currentTab: AppTab {
AppTab(rawValue: selection) ?? .main
}
/// : (snappy) .
private var openCurve: Animation { .spring(response: 0.3, dampingFraction: 0.7, blendDuration: 0) }
/// : .
private var closeCurve: Animation { .spring(response: 0.25, dampingFraction: 0.9, blendDuration: 0) }
var body: some View {
ZStack {
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
GeometryReader { geo in
//
let radius = min(desiredRadius, geo.size.width / 2 - bubbleSize / 2 - edgePadding)
// FAB( ) .
ZStack {
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
capsuleItem(tab, index: index)
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
// FAB( ) .
ZStack {
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)
.padding(.bottom, bottomInset)
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, bottomInset)
}
.ignoresSafeArea(.keyboard)
}
// MARK: ( + )
// MARK:
/// . ultraThinMaterial
/// .
/// padding / .
private func capsuleItem(_ tab: AppTab, index: Int) -> some View {
// index 0( ) FAB , .
let restY = -(firstItemGap + CGFloat(index) * itemSpacing)
/// . FAB
/// , .
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: {
// ' '
// ( ) .
ZStack(alignment: .leading) {
ForEach(tabs) { t in
capsuleContent(t)
.opacity(t == tab ? 1 : 0)
.accessibilityHidden(t != tab)
}
}
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background {
ZStack {
//
ZStack {
Capsule().fill(.ultraThinMaterial)
// ( )
Capsule().fill(AppTheme.green.opacity(isCurrent ? 0.16 : 0))
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)
// ( )
Text(tab.label)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
.fixedSize()
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
.offset(y: labelOffset)
}
.overlay(
//
Capsule().stroke(Color.white.opacity(0.4), lineWidth: 1)
)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
}
.buttonStyle(RadialPressStyle())
.accessibilityLabel(Text(tab.label))
// : FAB (offset 0, ) .
// : FAB .
.offset(y: expanded ? restY : 0)
.scaleEffect(expanded ? 1 : 0.3, anchor: .bottom)
// : 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
)
// FAB .
.allowsHitTesting(expanded)
}
/// ( + ). ZStack .
private func capsuleContent(_ tab: AppTab) -> some View {
HStack(spacing: 8) {
Image(systemName: tab.symbol)
.font(.system(size: 15, weight: .semibold))
.frame(width: 20)
Text(tab.label)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.fixedSize()
}
}
// MARK:
private var fab: some View {
@ -213,25 +224,26 @@ struct FloatingCapsuleMenu: View {
// MARK:
private func toggle() {
withAnimation(expanded ? closeCurve : openCurve) {
// per-item , /FAB .
withAnimation(.easeOut(duration: 0.2)) {
expanded.toggle()
}
}
private func close() {
withAnimation(closeCurve) {
withAnimation(.easeOut(duration: 0.18)) {
expanded = false
}
}
private func select(_ tab: AppTab) {
// : .
// : .
selection = tab.rawValue
close()
}
}
/// FAB· ( ). easeOut .
/// FAB· ( ). easeOut .
private struct RadialPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label

View File

@ -70,20 +70,20 @@ struct SettingsView: View {
Text(style.label).tag(style.rawValue)
}
}
//
//
if navStyleRaw == NavStyle.radial.rawValue {
NavigationLink {
RadialOrderView()
} label: {
Label("캡슐 메뉴 순서 편집", systemImage: "arrow.up.arrow.down")
Label("버블 메뉴 순서 편집", systemImage: "arrow.up.arrow.down")
}
}
} header: {
Text("내비게이션")
} footer: {
Text("플로팅 글래스 캡슐’을 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 알약 형태의 투명한 메뉴가 위로 팝업돼요. (실험적 기능)")
Text("글래스 버블 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌렀을 때 유리 구슬 모양의 탭 버튼들이 부채꼴로 퍼져요. 모든 탭이 엄지 반경 안에 있어 한 손으로 조작하기 편해요. (실험적 기능)")
}
//
//
if navStyleRaw == NavStyle.tabBar.rawValue {
Section {
ForEach(AppTab.allCases) { tab in
@ -287,9 +287,9 @@ struct SettingsView: View {
}
}
// MARK: - (iPhone )
// MARK: - (iPhone )
/// ( ) .
/// .
/// () , iPad·Mac .
struct RadialOrderView: View {
@AppStorage(SettingsKeys.radialTabOrder) private var radialOrderRaw = ""
@ -311,13 +311,13 @@ struct RadialOrderView: View {
radialOrderRaw = order.map(\.rawValue).joined(separator: ",")
}
} footer: {
Text("캡슐 메뉴를 펼쳤을 때 아래(버튼과 가까운 쪽)에서 위로 이 순서대로 캡슐이 배치돼요. 손잡이를 끌어 순서를 바꾸세요.")
Text("버블 메뉴를 펼쳤을 때 왼쪽 끝에서 위를 지나 오른쪽 끝으로 이 순서대로 버블이 배치돼요. 손잡이를 끌어 순서를 바꾸세요.")
}
}
.environment(\.editMode, .constant(.active))
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("캡슐 메뉴 순서")
.navigationTitle("버블 메뉴 순서")
.navigationBarTitleDisplayMode(.inline)
.onAppear { order = AppTab.radialOrder(from: radialOrderRaw) }
}

View File

@ -29,7 +29,7 @@ enum SettingsKeys {
static let goalCardStyle = "settings.goalCardStyle"
/// iPhone . NavStyle rawValue ("tabBar" | "radial"). ()
static let navStyle = "settings.navStyle"
/// . AppTab rawValue . ()
/// . AppTab rawValue . ()
static let radialTabOrder = "settings.radialTabOrder"
}
@ -37,7 +37,7 @@ enum SettingsKeys {
enum NavStyle: String, CaseIterable, Identifiable {
/// ( + )
case tabBar
/// (Floating Glass Capsules) rawValue radial
/// (Glass Bubbles) rawValue radial
case radial
var id: String { rawValue }
@ -45,7 +45,7 @@ enum NavStyle: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .tabBar: return String(localized: "기본 하단 탭 바")
case .radial: return String(localized: "플로팅 글래스 캡슐")
case .radial: return String(localized: "글래스 버블 메뉴")
}
}
}