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
228 lines
9.3 KiB
Swift
228 lines
9.3 KiB
Swift
//
|
|
// RadialNavigationView.swift
|
|
// Haru_Danim
|
|
//
|
|
// [실험적] iPhone 전용 플로팅 내비게이션 (설정 → 내비게이션 방식에서 전환).
|
|
//
|
|
// 구조:
|
|
// - 하단 시스템 탭바를 완전히 숨긴 TabView로 7개 탭의 화면·내비게이션 상태를 그대로 보존하고
|
|
// (탭 전환은 중앙 FAB 메뉴가 담당),
|
|
// - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 메뉴 오버레이를 얹는다.
|
|
// → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다.
|
|
//
|
|
// 메뉴 UI: '독립형 글래스 캡슐(Floating Glass Capsules)'.
|
|
// - 거대한 공통 반원 배경을 두지 않고, FAB를 누르면 각 탭이 독립된 알약(Capsule)으로
|
|
// 위쪽으로 통통 튀어 오른다. 각 캡슐은 ultraThinMaterial + 얇은 흰 테두리 + 부드러운 그림자로
|
|
// 유리처럼 떠 있고, 아이콘+텍스트가 넉넉한 패딩 안에 담겨 절대 밖으로 삐져나오지 않는다.
|
|
// - 등장 애니메이션은 무거운 블러 트랜지션 없이 offset·scale·opacity + 짧고 탄력 있는 스프링만 사용.
|
|
//
|
|
// 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.allCases) { tab in
|
|
Tab(tab.label, systemImage: tab.symbol, value: tab.rawValue) {
|
|
NavigationStack {
|
|
tab.rootView
|
|
}
|
|
.toolbar(.hidden, for: .tabBar)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) 플로팅 캡슐 메뉴 — 항상 최상단. collapsed 상태에선 FAB만 히트테스트를 받는다.
|
|
FloatingCapsuleMenu(
|
|
expanded: $menuExpanded,
|
|
selection: $router.tabSelection
|
|
)
|
|
}
|
|
.environment(router)
|
|
}
|
|
}
|
|
|
|
// MARK: - 플로팅 글래스 캡슐 메뉴 오버레이
|
|
|
|
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 bottomInset: CGFloat = 42
|
|
/// 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
|
|
}
|
|
|
|
/// 펼침: 짧고 탄력 있는(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() }
|
|
|
|
// FAB(하단 중앙)를 기준점으로 캡슐들이 위쪽으로 펼쳐진다.
|
|
ZStack {
|
|
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
|
capsuleItem(tab, index: index)
|
|
}
|
|
fab
|
|
}
|
|
.frame(width: fabSize, height: fabSize)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
.padding(.bottom, bottomInset)
|
|
}
|
|
.ignoresSafeArea(.keyboard)
|
|
}
|
|
|
|
// MARK: 개별 글래스 캡슐 (아이콘 + 텍스트)
|
|
|
|
/// 각 탭을 독립된 유리 알약으로 렌더링. 배경 뒤가 은은히 비치는 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(
|
|
// 빛을 받는 유리 엣지 느낌
|
|
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)
|
|
.opacity(expanded ? 1 : 0)
|
|
// 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다.
|
|
.allowsHitTesting(expanded)
|
|
}
|
|
|
|
// MARK: 중앙 플로팅 버튼
|
|
|
|
private var fab: some 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)
|
|
}
|
|
.buttonStyle(RadialPressStyle())
|
|
.accessibilityLabel(expanded ? Text("메뉴 닫기") : Text("탭 메뉴 열기"))
|
|
}
|
|
|
|
// MARK: 동작
|
|
|
|
private func toggle() {
|
|
withAnimation(expanded ? closeCurve : openCurve) {
|
|
expanded.toggle()
|
|
}
|
|
}
|
|
|
|
private func close() {
|
|
withAnimation(closeCurve) {
|
|
expanded = false
|
|
}
|
|
}
|
|
|
|
private func select(_ tab: AppTab) {
|
|
// 스펙: 아이콘을 누르면 즉시 라우팅하고 메뉴는 닫힌다.
|
|
selection = tab.rawValue
|
|
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)
|
|
}
|
|
}
|