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:
songyc macbook 2026-07-12 09:01:50 +09:00
parent f7ddd42260
commit 8f2b5fab93
5 changed files with 406 additions and 71 deletions

View File

@ -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
}

View File

@ -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" : {

View 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)
}
}

View File

@ -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 {

View File

@ -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: "중앙 플로팅 반원 메뉴")
}
}
}
/// ( )