From 8f2b5fab93f1f31a881d42efa4a5e6efe26e0975 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sun, 12 Jul 2026 09:01:50 +0900 Subject: [PATCH] feat(nav): add experimental iPhone radial floating navigation menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/ContentView.swift | 23 +- myApp/HaruDanim/IOS/Localizable.xcstrings | 179 +++++++++----- .../IOS/Views/RadialNavigationView.swift | 230 ++++++++++++++++++ myApp/HaruDanim/IOS/Views/SettingsView.swift | 26 +- myApp/HaruDanim/Shared/Settings.swift | 19 ++ 5 files changed, 406 insertions(+), 71 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Views/RadialNavigationView.swift diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 72181e5..95f98c0 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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 } diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 37c34b9..f090921 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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" : { diff --git a/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift new file mode 100644 index 0000000..d213387 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/RadialNavigationView.swift @@ -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) + } +} diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 049cdb1..658c2fb 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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 { diff --git a/myApp/HaruDanim/Shared/Settings.swift b/myApp/HaruDanim/Shared/Settings.swift index db9bf10..79f5edc 100644 --- a/myApp/HaruDanim/Shared/Settings.swift +++ b/myApp/HaruDanim/Shared/Settings.swift @@ -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: "중앙 플로팅 반원 메뉴") + } + } } /// 모음 탭 목표 진행 현황 카드의 표시 방식 (선택된 모든 목표 카드에 일괄 적용)