SwiftUI Adaptive Layout
Overview
Discipline-enforcing skill for building layouts that respond to available space rather than device assumptions. Covers tool selection, size class limitations, iOS 26 free-form windows, and common anti-patterns.
Core principle: Your layout should work correctly if Apple ships a new device tomorrow, or if iPadOS adds a new multitasking mode next year. Respond to your container, not your assumptions about the device.
When to Use This Skill
- "How do I make this layout work on iPad and iPhone?"
- "Should I use GeometryReader or ViewThatFits?"
- "My layout breaks in Split View / Stage Manager"
- "Size classes aren't giving me what I need"
- "Designer wants different layout for portrait vs landscape"
- "Preparing app for iOS 26 window resizing"
Decision Tree
"I need my layout to adapt..."
β
ββ TO AVAILABLE SPACE (container-driven)
β β
β ββ "Pick best-fitting variant"
β β β ViewThatFits
β β
β ββ "Animated switch between HβV"
β β β AnyLayout + condition
β β
β ββ "Read size for calculations"
β β β onGeometryChange (iOS 16+)
β β
β ββ "Custom layout algorithm"
β β Layout protocol
β
ββ TO PLATFORM TRAITS
β β
β ββ "Compact vs Regular width"
β β β horizontalSizeClass (β οΈ iPad limitations)
β β
β ββ "Accessibility text size"
β β β dynamicTypeSize.isAccessibilitySize
β β
β ββ "Platform differences"
β β #if os() / Environment
β
ββ TO WINDOW SHAPE (aspect ratio)
β
ββ "Portrait vs Landscape semantics"
β β Geometry + custom threshold
β
ββ "Auto show/hide columns"
β β NavigationSplitView (automatic in iOS 26)
β
ββ "Window lifecycle"
β @Environment(\.scenePhase)
Tool Selection
Quick Decision
Do you need a calculated value (width, height)?
ββ YES β onGeometryChange
ββ NO β Do you need animated transitions?
ββ YES β AnyLayout + condition
ββ NO β ViewThatFits
When to Use Each Tool
| I need to... |
Use this |
Not this |
| Pick between 2-3 layout variants |
ViewThatFits |
if size > X |
| Switch HβV with animation |
AnyLayout |
Conditional HStack/VStack |
| Read container size |
onGeometryChange |
GeometryReader |
| Adapt to accessibility text |
dynamicTypeSize |
Fixed breakpoints |
| Detect compact width |
horizontalSizeClass |
UIDevice.idiom |
| Detect narrow window on iPad |
Geometry + threshold |
Size class alone |
| Hide/show sidebar |
NavigationSplitView |
Manual column logic |
| Custom layout algorithm |
Layout protocol |
Nested GeometryReaders |
Pattern 1: ViewThatFits
Use when: You have 2-3 layout variants and want SwiftUI to pick the first that fits.
ViewThatFits {
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}
Limitation: ViewThatFits doesn't expose which variant was chosen. If you need that state for other views, use AnyLayout instead.
Pattern 2: AnyLayout for Animated Switching
Use when: You need animated transitions between layouts, or need to know current layout state.
struct AdaptiveStack<Content: View>: View {
@Environment(\.horizontalSizeClass) var sizeClass
let content: Content
var layout: AnyLayout {
sizeClass == .compact
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 20))
}
var body: some View {
layout {
content
}
.animation(.default, value: sizeClass)
}
}
For Dynamic Type:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}
Pattern 3: onGeometryChange (Preferred for Geometry)
Use when: You need actual dimensions for calculations. Preferred over GeometryReader.
struct ResponsiveGrid: View {
@State private var columnCount = 2
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount)) {
ForEach(items) { item in
ItemView(item: item)
}
}
.onGeometryChange(for: Int.self) { proxy in
max(1, Int(proxy.size.width / 150))
} action: { newCount in
columnCount = newCount
}
}
}
For aspect ratio detection (iPad "orientation"):
struct WindowShapeReader: View {
@State private var isWide = true
var body: some View {
content
.onGeometryChange(for: Bool.self) { proxy in
proxy.size.width > proxy.size.height * 1.2
} action: { newValue in
isWide = newValue
}
}
}
Pattern 4: GeometryReader (When Necessary)
Use when: You need geometry AND are on iOS 15 or earlier, OR need geometry during layout phase (not just as side effect).
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
.frame(height: 44)
Button("Next") { }
}
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
Button("Next") { }
}
Size Class Truth Table (iPad)
| Configuration |
Horizontal |
Vertical |
| Full screen portrait |
.regular |
.regular |
| Full screen landscape |
.regular |
.regular |
| 70% Split View |
.regular |
.regular |
| 50% Split View |
.regular |
.regular |
| 33% Split View |
.compact |
.regular |
| Slide Over |
.compact |
.regular |
| With keyboard |
(unchanged) |
(unchanged) |
Key insight: Size class only goes .