From 9e81cd3d030e0a6924dcfea950ac12b5e35bdbb3 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Sat, 11 Jul 2026 09:28:06 +0900 Subject: [PATCH] feat: auto-adapt layout for iPadOS/macOS with NavigationSplitView and hide irrelevant settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../Haru_Danim.xcodeproj/project.pbxproj | 6 ++ myApp/HaruDanim/IOS/ContentView.swift | 25 +++++- myApp/HaruDanim/IOS/Views/MainView.swift | 50 +++++++++--- myApp/HaruDanim/IOS/Views/SettingsView.swift | 17 ++-- .../HaruDanim/IOS/Views/SidebarRootView.swift | 79 +++++++++++++++++++ 5 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Views/SidebarRootView.swift diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj index 9d306ac..6a13c88 100644 --- a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj @@ -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; diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index e85be63..72181e5 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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 diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index 4e145e6..e586882 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -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)) diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index c0c4da7..2d4fedf 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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 { diff --git a/myApp/HaruDanim/IOS/Views/SidebarRootView.swift b/myApp/HaruDanim/IOS/Views/SidebarRootView.swift new file mode 100644 index 0000000..bf18ab2 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/SidebarRootView.swift @@ -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 { + 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) +}