refactor(ui): redesign floating menu to detached glass capsules for better aesthetics and performance

Replace the single semicircle dome + arc-arranged icons (which distorted and
pushed labels outside the fan) with independent floating glass capsules that pop
up in a vertical stack from the FAB.

- Each tab is now a self-sizing Capsule: icon + label with generous padding so
  text can never overflow; ultraThinMaterial background, thin white translucent
  border (.stroke white 0.4), and a soft drop shadow for a floating glass look.
- Current tab gets a subtle green tint behind the content.
- Removed the heavy DomeShape glass fan and its scale/opacity blur transition
  plus all per-item arc math; appearance is driven purely by offset + scale +
  opacity under one snappy spring (response 0.3, damping 0.7), so no per-item
  blur passes and no progressive frame drops.
- Renamed RadialMenu -> FloatingCapsuleMenu; RadialTabView / settings keys /
  nav-style routing unchanged.

Verified: BUILD SUCCEEDED (iPhone 17 Pro sim) and expanded-state screenshot
confirms all 7 capsules render legibly with text fully inside each pill, no
distortion, current tab highlighted.

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 00:06:55 +09:00
parent dd95dcd28f
commit bd60697419

View File

@ -2,14 +2,20 @@
// RadialNavigationView.swift
// Haru_Danim
//
// [] iPhone (Radial) ( ).
// [] iPhone ( ).
//
// :
// - TabView 7 ·
// ( FAB ),
// - (ZStack ) .
// - (ZStack ) .
// FAB/ · .
//
// UI: ' (Floating Glass Capsules)'.
// - , FAB (Capsule)
// . ultraThinMaterial + +
// , + .
// - offset·scale·opacity + .
//
// iPhone(Compact) iPad·Mac SidebarRootView, watch .
//
@ -23,7 +29,7 @@ struct RadialTabView: View {
init() {
let router = AppRouter()
// '' .
// '' .
router.allTabsDirect = true
_router = State(initialValue: router)
#if DEBUG
@ -48,8 +54,8 @@ struct RadialTabView: View {
}
}
// 2) . collapsed FAB .
RadialMenu(
// 2) . collapsed FAB .
FloatingCapsuleMenu(
expanded: $menuExpanded,
selection: $router.tabSelection
)
@ -58,94 +64,105 @@ struct RadialTabView: View {
}
}
// MARK: -
// MARK: -
struct RadialMenu: View {
struct FloatingCapsuleMenu: 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 itemSize: CGFloat = 52
private let desiredRadius: CGFloat = 150
private let bottomInset: CGFloat = 42
private let edgePadding: CGFloat = 18
/// ( )
private let labelOffset: CGFloat = 40
/// FAB ( )
private let firstItemGap: CGFloat = 78
///
private let itemSpacing: CGFloat = 56
/// ( )
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
private var currentTab: AppTab {
AppTab(rawValue: selection) ?? .main
}
/// / . easeOut .
private var openCurve: Animation { .easeOut(duration: 0.24) }
private var closeCurve: Animation { .easeIn(duration: 0.18) }
/// : (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 {
GeometryReader { geo in
//
let radius = min(desiredRadius, geo.size.width / 2 - itemSize / 2 - edgePadding)
ZStack {
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
// FAB( ) .
ZStack {
// . .
Color.black
.opacity(expanded ? 0.22 : 0)
.ignoresSafeArea()
.allowsHitTesting(expanded)
.onTapGesture { close() }
// FAB( ) .
ZStack {
// ' ' .
// ( · ultraThinMaterial 15
// )
if expanded {
glassFan(radius: radius)
.transition(.scale(scale: 0.4, anchor: .bottom).combined(with: .opacity))
}
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
arcItem(tab, index: index, count: tabs.count, radius: radius)
}
fab
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
capsuleItem(tab, index: index)
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, bottomInset)
fab
}
.frame(width: fabSize, height: fabSize)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, bottomInset)
}
.ignoresSafeArea(.keyboard)
}
// MARK: ( )
// MARK: ( + )
/// . (ultraThinMaterial) +
/// + . .
private func glassFan(radius: CGFloat) -> some View {
let r = radius + itemSize / 2 + 12 //
let skirt = itemSize / 2 + 8 // ( )
return DomeShape(skirt: skirt)
.fill(.ultraThinMaterial)
/// . ultraThinMaterial
/// .
/// padding / .
private func capsuleItem(_ tab: AppTab, index: Int) -> some View {
// index 0( ) FAB , .
let restY = -(firstItemGap + CGFloat(index) * itemSpacing)
let isCurrent = tab == currentTab
return Button {
select(tab)
} label: {
HStack(spacing: 8) {
Image(systemName: tab.symbol)
.font(.system(size: 15, weight: .semibold))
Text(tab.label)
.font(.system(size: 15, weight: .semibold))
.lineLimit(1)
.fixedSize()
}
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background {
ZStack {
Capsule().fill(.ultraThinMaterial)
// ( )
Capsule().fill(AppTheme.green.opacity(isCurrent ? 0.16 : 0))
}
}
.overlay(
DomeShape(skirt: skirt).fill(
LinearGradient(
colors: [.white.opacity(0.30), .white.opacity(0.05)],
startPoint: .top, endPoint: .bottom
)
)
//
Capsule().stroke(Color.white.opacity(0.4), lineWidth: 1)
)
.overlay(DomeShape(skirt: skirt).stroke(.white.opacity(0.2), lineWidth: 1))
.frame(width: 2 * r, height: r + skirt)
// FAB .
.offset(y: -(r - skirt) / 2)
.shadow(color: .black.opacity(0.18), radius: 14, y: 6)
.allowsHitTesting(false)
.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)
.opacity(expanded ? 1 : 0)
// FAB .
.allowsHitTesting(expanded)
}
// MARK:
@ -166,7 +183,7 @@ struct RadialMenu: View {
)
)
.overlay(Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14)))
.overlay(Circle().strokeBorder(.white.opacity(0.22), lineWidth: 1))
.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)
@ -179,54 +196,6 @@ struct RadialMenu: View {
.accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기"))
}
// MARK: ( )
private func arcItem(_ 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 {
// . ( ).
ZStack {
if isCurrent {
Circle()
.fill(AppTheme.green.opacity(0.9))
.overlay(Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1))
}
Image(systemName: tab.symbol)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isCurrent ? .white : .primary)
}
.frame(width: itemSize, height: itemSize)
// +
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)
}
}
.buttonStyle(RadialPressStyle())
.accessibilityLabel(Text(tab.label))
// (FAB) , .
// (per-item · withAnimation )
.offset(x: expanded ? dx : 0, y: expanded ? dy : 0)
.scaleEffect(expanded ? 1 : 0.3)
.opacity(expanded ? 1 : 0)
// FAB .
.allowsHitTesting(expanded)
}
// MARK:
private func toggle() {
@ -248,28 +217,7 @@ struct RadialMenu: View {
}
}
/// () + . .
private struct DomeShape: Shape {
/// ( )
var skirt: CGFloat = 0
func path(in rect: CGRect) -> Path {
var p = Path()
let r = rect.width / 2
let cx = rect.midX
let baseY = rect.maxY - skirt
p.addArc(
center: CGPoint(x: cx, y: baseY), radius: r,
startAngle: .degrees(180), endAngle: .degrees(360), clockwise: false
)
p.addLine(to: CGPoint(x: cx + r, y: rect.maxY))
p.addLine(to: CGPoint(x: cx - r, y: rect.maxY))
p.closeSubpath()
return p
}
}
/// FAB· ( ). easeOut .
/// FAB· ( ). easeOut .
private struct RadialPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label