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:
songyc macbook 2026-07-11 09:28:06 +09:00
parent 2567b53b71
commit 9e81cd3d03
5 changed files with 155 additions and 22 deletions

View File

@ -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;

View File

@ -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

View File

@ -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))

View File

@ -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 {

View 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)
}