Adaptive Layout — iPhone & iPad
Core Principle
Let SwiftUI adapt automatically. Never check UIDevice.current.userInterfaceIdiom or UIScreen.main.bounds for layout decisions. Use size classes and adaptive containers instead.
Navigation
NavigationSplitView (primary pattern for list-detail)
Use NavigationSplitView for ANY list-detail flow. It collapses to NavigationStack on iPhone and shows sidebar+detail on iPad — zero conditional code.
@State private var selectedItem: Item?
NavigationSplitView {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) { ItemRow(item: item) }
}
.navigationTitle("Items")
} detail: {
if let selectedItem {
ItemDetailView(item: selectedItem)
} else {
ContentUnavailableView("Select an Item", systemImage: "doc")
}
}
Three-column pattern (sidebar categories → list → detail):
NavigationSplitView {
List(categories, selection: $selectedCategory) { ... }
} content: {
List(filteredItems, selection: $selectedItem) { ... }
} detail: {
if let selectedItem { DetailView(item: selectedItem) }
else { ContentUnavailableView(...) }
}
Use NavigationStack ONLY for purely linear flows (onboarding, checkout).
Size Classes
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
| Context | horizontalSizeClass |
|---|---|
| iPhone portrait | .compact |
| iPhone landscape | .compact |
| iPad full-screen | .regular |
| iPad Split View (narrow) | .compact |
| iPad Split View (wide) | .regular |
Critical: iPad in Split View multitasking can report .compact. This is why UIDevice.current is wrong — always use size classes.
Switching layout axis:
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
let layout = sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 16))
: AnyLayout(HStackLayout(spacing: 24))
layout {
ContentBlockA()
ContentBlockB()
}
}
ViewThatFits (component-level adaptation)
Use when the decision is purely about available space — no environment reading needed:
ViewThatFits {
HStack(spacing: 16) { icon; title; subtitle; actionButton }
VStack(alignment: .leading, spacing: 8) {
HStack { icon; title }
subtitle
actionButton
}
}
When to use which:
ViewThatFits→ component-level (a card, a header, a toolbar item)horizontalSizeClass→ screen-level (different page structures)
Adaptive Grids
Always use GridItem(.adaptive(minimum:maximum:)) — never hardcode column counts:
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 160, maximum: 320))],
spacing: 16
) {
ForEach(items) { item in CardView(item: item) }
}
.padding()
Automatically: 2 columns on iPhone, 3-4 on iPad portrait, 4-6 on iPad landscape.
Presentations — Sheet & Popover
Popovers auto-adapt
.popover(isPresented: $showOptions) {
OptionsView()
}
- iPad (regular): floating popover anchored to source
- iPhone (compact): automatically becomes a sheet
Sheet behavior
.sheet(isPresented: $showSheet) {
SheetContent()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
- iPhone portrait: respects detents
- iPad wide: centered floating modal (detents ignored)
Confirmation dialogs
Always use .confirmationDialog — it becomes an action sheet on iPhone, popover on iPad:
.confirmationDialog("Options", isPresented: $showDialog) {
Button("Option A") { }
Button("Cancel", role: .cancel) { }
}
Spacing & Readability
- Use
.padding()with no arguments — SwiftUI applies 16pt on iPhone, 20pt on iPad automatically. - For long-form text on wide screens, constrain readability:
ScrollView {
content
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
}
- Use
@ScaledMetricfor custom dimensions that respect Dynamic Type:
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 24
Relative Sizing
Prefer containerRelativeFrame over GeometryReader:
Image("photo")
.containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 16)
Reserve GeometryReader only for complex calculations (parallax, custom alignment).
STRICT RULES
- NEVER use
UIDevice.current.userInterfaceIdiomfor layout — breaks iPad multitasking - NEVER use
UIScreen.main.boundsfor sizing — doesn't respond to multitasking - NEVER use
#if targetEnvironmentfor layout decisions - NEVER hardcode frame widths (e.g.,
.frame(width: 375)) - NEVER hardcode grid column counts — use
.adaptive(minimum:) - ALWAYS use Dynamic Type text styles (
.font(.body),.font(.title)) - ALWAYS provide a detail placeholder in
NavigationSplitViewfor iPad empty state - ALWAYS use
.popoverfor contextual actions — SwiftUI auto-adapts - ALWAYS use
.leading/.trailing— never.left/.right
微信扫一扫