Widget configuration — delete the multi-slot pickers entirely -------------------------------------------------------------- Every home/lock widget exposed 3–4 separate entity slots (action1–4, goal1–4, quest1–4, secondGoal) regardless of family, cluttering the long-press config screen with slots a given size never renders. Replaced that with a single anchor selection per widget that self-configures by size family (the view already prefixes by family; providers now build the candidate list from one selection): - ActionRun (A): one anchor Action → fills following actions (main-tab order) up to the family's count. - GoalBars (B): one anchor Goal → fills following goals up to the count. - QuestRing (C): one Goal → renders that goal's quests, sized by family. - GoalQuestGrid (D): one Goal (dropped secondGoal) → its quests; large now shows up to 16 rings of the single goal. - Stats: one anchor Action → plus following actions on the same chart. New WidgetStore helpers actionsFrom/goalsFrom/questsOfGoal centralize the "anchor + following / goal's quests" logic; the None sentinel is ignored gracefully so clearing falls back to sensible defaults. No @Model, timeline, or reload/sync logic touched. Floating radial menu (iPhone) — performance + glassmorphism ----------------------------------------------------------- Each of the ~7 icons and ~7 labels had its own .ultraThinMaterial, so the expanded menu ran ~15 separate blur passes that re-rendered every frame as content scrolled behind them — the source of the progressive frame drops. Now a single unified glass dome (one DomeShape filled with .ultraThinMaterial) sits behind the whole fan and is the only blur; icons are lightweight symbols (only the current tab gets a solid green chip) and labels are plain text with a legibility shadow. The dome is styled per glassmorphism: a soft white top-to-bottom gradient overlay, a thin white.opacity(0.2) border, and a soft shadow; the FAB gets the same gradient/border treatment. Animation is snappier and lighter: the per-item implicit spring animations with staggered delays are gone — one withAnimation(.easeOut ~0.24 open / .easeIn ~0.18 close) drives the whole fan, and the press style uses a short easeOut instead of a spring. Verified in the simulator: build succeeds; the expanded menu renders the single glass dome with all tabs and no crash (light/dark, expanded/collapsed); the refactored C/D widgets render from a single selected goal, self-sized by family. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
280 lines
12 KiB
Swift
280 lines
12 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
|
|
}
|
|
|
|
/// 열고/닫는 전환. 무거운 스프링 대신 짧고 빠른 easeOut으로 프레임 드랍을 줄인다.
|
|
private var openCurve: Animation { .easeOut(duration: 0.24) }
|
|
private var closeCurve: Animation { .easeIn(duration: 0.18) }
|
|
|
|
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 {
|
|
// 부채꼴 전체를 덮는 '단 하나의' 유리 배경 — 블러 연산을 한 번만 수행한다.
|
|
// (예전엔 아이콘·라벨마다 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
|
|
}
|
|
.frame(width: fabSize, height: fabSize)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
|
.padding(.bottom, bottomInset)
|
|
}
|
|
}
|
|
.ignoresSafeArea(.keyboard)
|
|
}
|
|
|
|
// 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)
|
|
.overlay(
|
|
DomeShape(skirt: skirt).fill(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.30), .white.opacity(0.05)],
|
|
startPoint: .top, endPoint: .bottom
|
|
)
|
|
)
|
|
)
|
|
.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)
|
|
}
|
|
|
|
// 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.22), 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 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() {
|
|
withAnimation(expanded ? closeCurve : openCurve) {
|
|
expanded.toggle()
|
|
}
|
|
}
|
|
|
|
private func close() {
|
|
withAnimation(closeCurve) {
|
|
expanded = false
|
|
}
|
|
}
|
|
|
|
private func select(_ tab: AppTab) {
|
|
// 스펙: 아이콘을 누르면 즉시 라우팅하고 메뉴는 닫힌다.
|
|
selection = tab.rawValue
|
|
close()
|
|
}
|
|
}
|
|
|
|
/// 위쪽 반원(돔) + 아래 짧은 스커트. 부채꼴 아이콘들을 하나의 유리 면으로 감싸는 배경 모양.
|
|
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으로 가볍게.
|
|
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)
|
|
}
|
|
}
|