feat(nav): add experimental iPhone radial floating navigation menu
Adds an alternative iPhone navigation style, switchable in Settings, that replaces the bottom tab bar with a single center floating button which fans all tabs out into an upper 180° semicircle. - Settings → 내비게이션: pick 기본 하단 탭 바 / 중앙 플로팅 반원 메뉴. Stored device-local via @AppStorage(SettingsKeys.navStyle) (not synced); the 탭바 구성 section auto-hides in radial mode since all tabs are shown. - iPhone (Compact) only: RootNavigationView branches on NavStyle. iPad/Mac sidebar and the watch target are untouched. - Radial menu (RadialNavigationView): glass FAB at bottom-center over a tab-bar-hidden TabView (per-tab navigation state preserved). Tapping the FAB blooms all 7 tabs into an upper 180° arc with per-icon name labels; tapping an icon routes immediately and closes; tapping the FAB or the dimming scrim closes. Current tab is highlighted. - Drops the 더보기 indirection in radial mode via AppRouter.allTabsDirect, so cross-tab routing (openHistory/openStats) selects tabs directly. - Liquid Glass (.ultraThinMaterial) styling on the FAB, icons and labels. - Overlay is kept top-most in the ZStack with hit-testing gated to the expanded state, so scrolling grid content can never intercept the button. - DEBUG: -navStyle radial / -radialExpanded YES launch args for simulator verification (no way to tap widgets/overlays via simctl). Scope: iPhone navigation only; no changes to iPad/Mac sidebar, the watch app, core data/models, or widget code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
f7ddd42260
commit
8f2b5fab93
@ -97,10 +97,27 @@ struct SplashView: View {
|
||||
// MARK: - 내비게이션 껍데기 분기 (iPhone=탭바, iPad·Mac=사이드바)
|
||||
|
||||
/// 기기 종류에 따라 내비게이션 구조만 바꾸고 각 화면(rootView)은 그대로 공유한다.
|
||||
/// iPhone은 설정(NavStyle)에 따라 기본 하단 탭바 / 플로팅 반원 메뉴 중 하나를 사용하고,
|
||||
/// iPad·Mac은 항상 사이드바를 사용한다 (반원 메뉴는 iPhone 전용).
|
||||
struct RootNavigationView: View {
|
||||
@AppStorage(SettingsKeys.navStyle) private var navStyleRaw = NavStyle.tabBar.rawValue
|
||||
|
||||
/// DEBUG 검증용: `-navStyle radial` 런치 인자로 설정과 무관하게 강제 전환
|
||||
private var effectiveNavStyle: NavStyle {
|
||||
#if DEBUG
|
||||
if let forced = UserDefaults.standard.string(forKey: "navStyle"),
|
||||
let style = NavStyle(rawValue: forced) {
|
||||
return style
|
||||
}
|
||||
#endif
|
||||
return NavStyle(rawValue: navStyleRaw) ?? .tabBar
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if DeviceLayout.isPad {
|
||||
SidebarRootView()
|
||||
} else if effectiveNavStyle == .radial {
|
||||
RadialTabView()
|
||||
} else {
|
||||
MainTabView()
|
||||
}
|
||||
@ -116,6 +133,10 @@ final class AppRouter {
|
||||
/// '더보기' 우회 없이 어떤 탭이든 바로 선택할 수 있다.
|
||||
var usesSidebar = false
|
||||
|
||||
/// 라디얼(플로팅 반원) 모드 여부. 반원 메뉴에도 모든 탭이 항상 노출되므로
|
||||
/// 사이드바와 마찬가지로 '더보기' 우회 없이 어떤 탭이든 바로 선택한다.
|
||||
var allTabsDirect = false
|
||||
|
||||
var tabSelection: String = {
|
||||
#if DEBUG
|
||||
if let raw = UserDefaults.standard.string(forKey: "startTab") {
|
||||
@ -147,7 +168,7 @@ final class AppRouter {
|
||||
}
|
||||
|
||||
private func open(_ tab: AppTab, visibleTabsRaw: String) {
|
||||
if usesSidebar {
|
||||
if usesSidebar || allTabsDirect {
|
||||
tabSelection = tab.rawValue
|
||||
return
|
||||
}
|
||||
|
||||
@ -156,6 +156,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"‘중앙 플로팅 반원 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌러 모든 탭을 반원으로 펼쳐 이동해요. (실험적 기능)" : {
|
||||
|
||||
},
|
||||
"%@" : {
|
||||
"localizations" : {
|
||||
@ -927,6 +930,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"가장 나중에 시작한 행동" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Most recently started action"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最後に開始したアクション"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"가장 먼저 시작한 행동" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -959,22 +978,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"가장 나중에 시작한 행동" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Most recently started action"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最後に開始したアクション"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"개발용" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@ -1007,6 +1010,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"건강한 생활 습관" : {
|
||||
|
||||
},
|
||||
"건너뛰기" : {
|
||||
"localizations" : {
|
||||
@ -1312,6 +1318,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"기본 하단 탭 바" : {
|
||||
|
||||
},
|
||||
"기호" : {
|
||||
"localizations" : {
|
||||
@ -1584,6 +1593,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"내비게이션" : {
|
||||
|
||||
},
|
||||
"내비게이션 방식" : {
|
||||
|
||||
},
|
||||
"누적 기록" : {
|
||||
"localizations" : {
|
||||
@ -1989,6 +2004,7 @@
|
||||
}
|
||||
},
|
||||
"다크 고정" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -2019,6 +2035,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"달리기" : {
|
||||
|
||||
},
|
||||
"달성" : {
|
||||
"localizations" : {
|
||||
@ -2212,6 +2231,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"독서" : {
|
||||
|
||||
},
|
||||
"동기화" : {
|
||||
"localizations" : {
|
||||
@ -2278,6 +2300,7 @@
|
||||
}
|
||||
},
|
||||
"라이트 고정" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -2294,6 +2317,7 @@
|
||||
}
|
||||
},
|
||||
"리퀴드 글라스" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -2410,6 +2434,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"메뉴 닫기" : {
|
||||
|
||||
},
|
||||
"메모" : {
|
||||
"localizations" : {
|
||||
@ -2475,22 +2502,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Home"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ホーム"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음 탭 목표 진행 현황" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -2524,6 +2535,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"모음" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Home"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ホーム"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"목록" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -3041,6 +3068,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"물 마시기" : {
|
||||
|
||||
},
|
||||
"미달성" : {
|
||||
"localizations" : {
|
||||
@ -3924,6 +3954,7 @@
|
||||
}
|
||||
},
|
||||
"앱 테마와 일치" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -3940,6 +3971,7 @@
|
||||
}
|
||||
},
|
||||
"앱에서 행동을 만들고 기록하면 그래프가 표시돼요." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -3956,6 +3988,7 @@
|
||||
}
|
||||
},
|
||||
"앱에서 행동을 만들면 여기서 실행할 수 있어요." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -3972,6 +4005,7 @@
|
||||
}
|
||||
},
|
||||
"앱의 목표 탭에서 다짐을 만들면 진행률이 표시돼요." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -3988,6 +4022,7 @@
|
||||
}
|
||||
},
|
||||
"앱의 목표 탭에서 목표를 만들면 진행률이 표시돼요." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -4004,6 +4039,7 @@
|
||||
}
|
||||
},
|
||||
"앱의 목표 탭에서 목표와 다짐을 만들어 보세요." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -4082,6 +4118,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"영상 시청" : {
|
||||
|
||||
},
|
||||
"예: 토익 700점 이상 받기" : {
|
||||
"localizations" : {
|
||||
@ -4389,6 +4428,7 @@
|
||||
}
|
||||
},
|
||||
"위젯 테마" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -4516,6 +4556,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"이하 유지" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Stay under"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "以下を維持"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일" : {
|
||||
"comment" : "요일 축약(일요일)",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sun"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "日"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"이번 주" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -4564,22 +4637,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"이하 유지" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Stay under"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "以下を維持"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"인기" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -4596,23 +4653,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"일" : {
|
||||
"comment" : "요일 축약(일요일)",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sun"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "日"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"일·업무" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -5031,6 +5071,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"중앙 플로팅 반원 메뉴" : {
|
||||
|
||||
},
|
||||
"즐겨찾기" : {
|
||||
"localizations" : {
|
||||
@ -5399,6 +5442,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"탭 메뉴 열기" : {
|
||||
|
||||
},
|
||||
"탭바 구성" : {
|
||||
"localizations" : {
|
||||
@ -5544,6 +5590,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"팔굽혀펴기" : {
|
||||
|
||||
},
|
||||
"표시 기간" : {
|
||||
"localizations" : {
|
||||
|
||||
230
myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
Normal file
230
myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
Normal file
@ -0,0 +1,230 @@
|
||||
//
|
||||
// RadialNavigationView.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// [실험적] iPhone 전용 플로팅 반원(Radial) 내비게이션 (설정 → 내비게이션 방식에서 전환).
|
||||
//
|
||||
// 구조:
|
||||
// - 하단 시스템 탭바를 완전히 숨긴 TabView로 7개 탭의 화면·내비게이션 상태를 그대로 보존하고
|
||||
// (탭 전환은 중앙 FAB 메뉴가 담당),
|
||||
// - 그 위에 항상 최상단(ZStack 마지막 자식)으로 떠 있는 반원 메뉴 오버레이를 얹는다.
|
||||
// → 모음 탭의 행동 그리드가 아무리 길게 스크롤돼도 FAB/메뉴는 스크롤·터치에 간섭받지 않는다.
|
||||
//
|
||||
// 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만 히트테스트를 받는다.
|
||||
RadialMenu(
|
||||
expanded: $menuExpanded,
|
||||
selection: $router.tabSelection
|
||||
)
|
||||
}
|
||||
.environment(router)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 반원 메뉴 오버레이
|
||||
|
||||
struct RadialMenu: View {
|
||||
@Binding var expanded: Bool
|
||||
/// AppTab.rawValue (라우터의 tabSelection과 직접 연결)
|
||||
@Binding var selection: String
|
||||
|
||||
// 레이아웃 상수
|
||||
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
|
||||
|
||||
private var tabs: [AppTab] { AppTab.allCases }
|
||||
|
||||
private var currentTab: AppTab {
|
||||
AppTab(rawValue: selection) ?? .main
|
||||
}
|
||||
|
||||
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 {
|
||||
ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
arcItem(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)
|
||||
}
|
||||
|
||||
// MARK: 중앙 플로팅 버튼
|
||||
|
||||
private var fab: some View {
|
||||
Button {
|
||||
toggle()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.overlay(
|
||||
Circle().fill(AppTheme.green.opacity(expanded ? 0.0 : 0.14))
|
||||
)
|
||||
Image(systemName: expanded ? "xmark" : currentTab.symbol)
|
||||
.font(.system(size: expanded ? 22 : 24, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||||
.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 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의 레이아웃 프레임 = 아이콘 원(itemSize)뿐이므로,
|
||||
// 라벨을 offset으로 아래에 띄워도 아이콘 중심은 반원 위 위치를 그대로 유지한다.
|
||||
ZStack {
|
||||
// 아이콘 원
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
Circle().fill(AppTheme.green.opacity(isCurrent ? 0.22 : 0))
|
||||
)
|
||||
.overlay(
|
||||
Circle().strokeBorder(
|
||||
isCurrent ? AppTheme.green.opacity(0.9) : .white.opacity(0.16),
|
||||
lineWidth: isCurrent ? 2 : 1
|
||||
)
|
||||
)
|
||||
Image(systemName: tab.symbol)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
|
||||
}
|
||||
.frame(width: itemSize, height: itemSize)
|
||||
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
|
||||
|
||||
// 탭 이름 라벨 (글라스 캡슐, 원 아래로)
|
||||
Text(tab.label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
.foregroundStyle(isCurrent ? AppTheme.green : .primary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2.5)
|
||||
.background(Capsule().fill(.ultraThinMaterial))
|
||||
.overlay(Capsule().strokeBorder(.white.opacity(0.14), lineWidth: 0.5))
|
||||
.shadow(color: .black.opacity(0.12), radius: 3, y: 1)
|
||||
.offset(y: labelOffset)
|
||||
}
|
||||
}
|
||||
.buttonStyle(RadialPressStyle())
|
||||
.accessibilityLabel(Text(tab.label))
|
||||
// 펼쳐질 때 중앙(FAB)에서 각 위치로 피어나고, 접힐 땐 중앙으로 모인다.
|
||||
.offset(x: expanded ? dx : 0, y: expanded ? dy : 0)
|
||||
.scaleEffect(expanded ? 1 : 0.3)
|
||||
.opacity(expanded ? 1 : 0)
|
||||
.animation(
|
||||
.spring(response: 0.42, dampingFraction: 0.72)
|
||||
.delay(expanded ? Double(index) * 0.035 : 0),
|
||||
value: expanded
|
||||
)
|
||||
// 접혀 있을 땐 FAB 아래 숨어 있으므로 터치를 받지 않게 한다.
|
||||
.allowsHitTesting(expanded)
|
||||
}
|
||||
|
||||
// MARK: 동작
|
||||
|
||||
private func toggle() {
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) {
|
||||
expanded.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func close() {
|
||||
withAnimation(.spring(response: 0.42, dampingFraction: 0.72)) {
|
||||
expanded = false
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ tab: AppTab) {
|
||||
// 스펙: 아이콘을 누르면 즉시 라우팅하고 메뉴는 닫힌다.
|
||||
selection = tab.rawValue
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/// FAB·아이콘 공통 눌림 효과 (살짝 줄어들며 반응)
|
||||
private struct RadialPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.9 : 1)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,8 @@ struct SettingsView: View {
|
||||
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
|
||||
private let premium = PremiumManager.shared
|
||||
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
||||
// 내비게이션 방식은 이 기기에서만 쓰는 UI 설정 → 동기화하지 않는 standard 저장소 사용
|
||||
@AppStorage(SettingsKeys.navStyle) private var navStyleRaw = NavStyle.tabBar.rawValue
|
||||
@AppStorage(SettingsKeys.goalCardStyle) private var goalCardStyle = GoalCardStyle.perQuest.rawValue
|
||||
// 모음 탭 노출 목표는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고)
|
||||
@AppStorage(LocalPrefsKeys.pinnedGoals, store: AppGroup.defaults) private var pinnedGoalsRaw = ""
|
||||
@ -60,16 +62,30 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
|
||||
}
|
||||
// iPad·Mac은 사이드바에 모든 탭이 항상 보이므로 탭바 구성 설정이 무의미하다
|
||||
// iPad·Mac은 사이드바에 모든 탭이 항상 보이므로 내비게이션·탭바 구성 설정이 무의미하다
|
||||
if !DeviceLayout.isPad {
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
tabToggleRow(tab)
|
||||
Picker("내비게이션 방식", selection: $navStyleRaw) {
|
||||
ForEach(NavStyle.allCases) { style in
|
||||
Text(style.label).tag(style.rawValue)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("탭바 구성")
|
||||
Text("내비게이션")
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
Text("‘중앙 플로팅 반원 메뉴’를 고르면 하단 탭바 대신 화면 하단 중앙의 버튼을 눌러 모든 탭을 반원으로 펼쳐 이동해요. (실험적 기능)")
|
||||
}
|
||||
// 반원 메뉴는 모든 탭을 항상 노출하므로 노출 탭 선택이 필요 없다
|
||||
if navStyleRaw == NavStyle.tabBar.rawValue {
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
tabToggleRow(tab)
|
||||
}
|
||||
} header: {
|
||||
Text("탭바 구성")
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
|
||||
@ -27,6 +27,25 @@ enum SettingsKeys {
|
||||
static let visibleTabs = "settings.visibleTabs"
|
||||
/// 모음 탭 목표 카드 표시 방식. GoalCardStyle rawValue ("perQuest" | "combined")
|
||||
static let goalCardStyle = "settings.goalCardStyle"
|
||||
/// iPhone 내비게이션 방식. NavStyle rawValue ("tabBar" | "radial"). 기기 로컬(비동기화)
|
||||
static let navStyle = "settings.navStyle"
|
||||
}
|
||||
|
||||
/// iPhone 전용 내비게이션 방식 (iPad·Mac 사이드바에는 적용되지 않음, 기기 로컬 설정)
|
||||
enum NavStyle: String, CaseIterable, Identifiable {
|
||||
/// 기본 하단 탭 바 (노출 탭 + 더보기)
|
||||
case tabBar
|
||||
/// 중앙 플로팅 반원(Radial) 메뉴
|
||||
case radial
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .tabBar: return String(localized: "기본 하단 탭 바")
|
||||
case .radial: return String(localized: "중앙 플로팅 반원 메뉴")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 모음 탭 목표 진행 현황 카드의 표시 방식 (선택된 모든 목표 카드에 일괄 적용)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user