feat: auto-adapt layout for iPadOS/macOS with NavigationSplitView and hide irrelevant settings
- Add SidebarRootView: iPad/Mac (Designed for iPad) uses a NavigationSplitView sidebar listing all 7 tabs, sharing every existing screen (rootView) untouched - Branch by device idiom (DeviceLayout.isPad) so iPhone keeps its tab bar UI, logic, and Live Activity behavior pixel-for-pixel, including landscape - AppRouter gains sidebar mode: cross-tab routing (기록 확인/통계 보기) selects the target tab directly instead of detouring through the More tab - Hide iPhone-only settings on iPad/Mac: 탭바 구성 section and the main grid's per-row column picker are meaningless with a sidebar/adaptive grid - Main tab wide-screen layout: pinned goal cards flow in an adaptive grid (340-520pt cards) instead of full-width paging; action buttons auto-fill columns at 150-220pt so nothing stretches on large displays - Verified on iPad Pro 11" simulator (sidebar, settings, stats routing, timetable) and iPhone 17 Pro (unchanged tab bar); both build clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
2567b53b71
commit
9e81cd3d03
@ -614,6 +614,9 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@ -650,6 +653,9 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
||||
@ -23,10 +23,10 @@ struct ContentView: View {
|
||||
} else if UserDefaults.standard.bool(forKey: "premiumPreview") {
|
||||
NavigationStack { PremiumView() }
|
||||
} else {
|
||||
MainTabView()
|
||||
RootNavigationView()
|
||||
}
|
||||
#else
|
||||
MainTabView()
|
||||
RootNavigationView()
|
||||
#endif
|
||||
if showSplash {
|
||||
SplashView()
|
||||
@ -94,11 +94,28 @@ struct SplashView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 내비게이션 껍데기 분기 (iPhone=탭바, iPad·Mac=사이드바)
|
||||
|
||||
/// 기기 종류에 따라 내비게이션 구조만 바꾸고 각 화면(rootView)은 그대로 공유한다.
|
||||
struct RootNavigationView: View {
|
||||
var body: some View {
|
||||
if DeviceLayout.isPad {
|
||||
SidebarRootView()
|
||||
} else {
|
||||
MainTabView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 탭 간 이동 라우터
|
||||
|
||||
/// 탭 전환과 탭 간 전달값 관리 (예: 모음 탭 '기록 확인' → 기록 탭 행동 필터)
|
||||
@Observable
|
||||
final class AppRouter {
|
||||
/// 사이드바(NavigationSplitView) 모드 여부. 사이드바에는 모든 탭이 항상 보이므로
|
||||
/// '더보기' 우회 없이 어떤 탭이든 바로 선택할 수 있다.
|
||||
var usesSidebar = false
|
||||
|
||||
var tabSelection: String = {
|
||||
#if DEBUG
|
||||
if let raw = UserDefaults.standard.string(forKey: "startTab") {
|
||||
@ -130,6 +147,10 @@ final class AppRouter {
|
||||
}
|
||||
|
||||
private func open(_ tab: AppTab, visibleTabsRaw: String) {
|
||||
if usesSidebar {
|
||||
tabSelection = tab.rawValue
|
||||
return
|
||||
}
|
||||
let visible = AppTab.visibleTabs(from: visibleTabsRaw)
|
||||
if visible.contains(tab) {
|
||||
tabSelection = tab.rawValue
|
||||
|
||||
@ -129,7 +129,18 @@ struct MainView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var goalArea: some View {
|
||||
if pinnedGoals.count == 1, let goal = pinnedGoals.first {
|
||||
if DeviceLayout.isPad {
|
||||
// 넓은 화면: 페이징 대신 카드를 여러 장 나란히 배치 (폭도 카드 단위로 제한)
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 340, maximum: 520), spacing: 10, alignment: .top)],
|
||||
alignment: .leading,
|
||||
spacing: 10
|
||||
) {
|
||||
ForEach(pinnedGoals) { goal in
|
||||
GoalSummaryCard(goal: goal)
|
||||
}
|
||||
}
|
||||
} else if pinnedGoals.count == 1, let goal = pinnedGoals.first {
|
||||
GoalSummaryCard(goal: goal)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
@ -207,30 +218,39 @@ struct MainView: View {
|
||||
|
||||
private var layoutControl: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("한 줄에 표시할 개수", systemImage: "square.grid.3x3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
Picker("한 줄에 표시할 개수", selection: $gridColumns) {
|
||||
ForEach(2...6, id: \.self) { n in
|
||||
Text("\(n)개").tag(n)
|
||||
// iPad·Mac은 화면 폭에 맞춰 열 개수가 자동이므로 한 줄 개수 설정을 숨긴다
|
||||
if !DeviceLayout.isPad {
|
||||
Label("한 줄에 표시할 개수", systemImage: "square.grid.3x3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AppTheme.green)
|
||||
Picker("한 줄에 표시할 개수", selection: $gridColumns) {
|
||||
ForEach(2...6, id: \.self) { n in
|
||||
Text("\(n)개").tag(n)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Text("버튼을 길게 눌러 끌면 순서를 바꿀 수 있어요.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: 그리드
|
||||
|
||||
/// iPhone: 설정한 한 줄 개수 고정 / iPad·Mac: 버튼 크기 기준으로 화면 폭에 맞춰 자동 채움
|
||||
private var gridLayout: [GridItem] {
|
||||
if DeviceLayout.isPad {
|
||||
return [GridItem(.adaptive(minimum: 150, maximum: 220), spacing: 12)]
|
||||
}
|
||||
return Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns)
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: gridColumns),
|
||||
spacing: 12
|
||||
) {
|
||||
LazyVGrid(columns: gridLayout, spacing: 12) {
|
||||
ForEach(actions) { action in
|
||||
cell(for: action)
|
||||
}
|
||||
@ -241,7 +261,11 @@ struct MainView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(for action: Action) -> some View {
|
||||
let base = ActionButtonCell(action: action, isEditing: isEditing, compact: gridColumns >= 5) {
|
||||
let base = ActionButtonCell(
|
||||
action: action,
|
||||
isEditing: isEditing,
|
||||
compact: DeviceLayout.isPad ? false : gridColumns >= 5
|
||||
) {
|
||||
handleTap(action)
|
||||
}
|
||||
.modifier(JiggleEffect(active: isEditing))
|
||||
|
||||
@ -58,14 +58,17 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("언어를 바꾸면 앱을 완전히 종료했다가 다시 실행했을 때 적용돼요.")
|
||||
}
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
tabToggleRow(tab)
|
||||
// iPad·Mac은 사이드바에 모든 탭이 항상 보이므로 탭바 구성 설정이 무의미하다
|
||||
if !DeviceLayout.isPad {
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
tabToggleRow(tab)
|
||||
}
|
||||
} header: {
|
||||
Text("탭바 구성")
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
} header: {
|
||||
Text("탭바 구성")
|
||||
} footer: {
|
||||
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
|
||||
}
|
||||
Section {
|
||||
if goals.isEmpty {
|
||||
|
||||
79
myApp/HaruDanim/IOS/Views/SidebarRootView.swift
Normal file
79
myApp/HaruDanim/IOS/Views/SidebarRootView.swift
Normal file
@ -0,0 +1,79 @@
|
||||
//
|
||||
// SidebarRootView.swift
|
||||
// Haru_Danim
|
||||
//
|
||||
// iPad · Mac(Designed for iPad) 전용 사이드바 내비게이션.
|
||||
// iPhone의 MainTabView와 화면(rootView)은 그대로 공유하고 껍데기만 NavigationSplitView로 바꾼다.
|
||||
// iPhone 쪽 UI/로직은 이 파일이 전혀 건드리지 않는다.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
/// 넓은 화면(iPad, Mac Designed for iPad) 전용 분기 판정.
|
||||
/// 크기 클래스 대신 기기 종류로 판정해 iPhone(가로 모드 포함)은 항상 기존 UI를 유지한다.
|
||||
enum DeviceLayout {
|
||||
static let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
struct SidebarRootView: View {
|
||||
@State private var router: AppRouter
|
||||
|
||||
init() {
|
||||
let router = AppRouter()
|
||||
router.usesSidebar = true
|
||||
_router = State(initialValue: router)
|
||||
}
|
||||
|
||||
private var selectedTab: AppTab {
|
||||
AppTab(rawValue: router.tabSelection) ?? .main
|
||||
}
|
||||
|
||||
/// 사이드바 선택과 라우터의 탭 선택(String)을 잇는 다리
|
||||
private var sidebarSelection: Binding<AppTab?> {
|
||||
Binding {
|
||||
AppTab(rawValue: router.tabSelection) ?? .main
|
||||
} set: { newValue in
|
||||
if let newValue {
|
||||
router.tabSelection = newValue.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: sidebarSelection) {
|
||||
Section {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
Label {
|
||||
Text(tab.label)
|
||||
} icon: {
|
||||
Image(systemName: tab.symbol)
|
||||
.foregroundStyle(AppTheme.green)
|
||||
}
|
||||
.tag(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("하루 다님")
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 230, max: 300)
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
selectedTab.rootView
|
||||
}
|
||||
// 탭을 바꾸면 상세 스택을 처음 화면으로 초기화
|
||||
.id(selectedTab)
|
||||
}
|
||||
.environment(router)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SidebarRootView()
|
||||
.modelContainer(for: [
|
||||
Tag.self, Action.self, TimeSession.self,
|
||||
CountEntry.self, Goal.self, Quest.self,
|
||||
], inMemory: true)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user