mycode/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift
songyc macbook a02e06a9df feat: add reordering to floating radial menu and fix weekly timetable step
Radial menu icon order (iPhone floating nav only):
- Settings → 내비게이션 gains a '반원 메뉴 순서 편집' link (shown only when the
  radial style is selected) that opens a drag-to-reorder list of all 7 tabs.
- Order is stored device-local via @AppStorage(SettingsKeys.radialTabOrder)
  (not CloudKit-synced); RadialMenu reads it through AppTab.radialOrder(from:),
  which always yields the full tab set and appends any missing/unknown value in
  default spec order — so partial or legacy strings can never drop a tab.
- iPad/Mac sidebars and the watch target are untouched (they never read the key).

History timetable date step:
- The date header </> buttons now jump by a full week (7 days) when the
  timetable is in '일주일' view, and by 1 day otherwise (list / daily timetable).
- The calendar popover and '오늘' still set the date directly, so those paths and
  record loading are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-12 09:13:20 +09:00

235 lines
9.4 KiB
Swift

//
// 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
/// ( , )
@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
/// ( )
private var tabs: [AppTab] { AppTab.radialOrder(from: radialOrderRaw) }
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)
}
}