Loading...
Loading...
Use when layouts need to adapt to different screen sizes, iPad multitasking, or iOS 26 free-form windows — decision trees for ViewThatFits vs AnyLayout vs onGeometryChange, size class limitations, and anti-patterns preventing device-based layout mistakes
npx skill4agent add charleswiltgen/axiom axiom-swiftui-layout"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)Do you need a calculated value (width, height)?
├─ YES → onGeometryChange
└─ NO → Do you need animated transitions?
├─ YES → AnyLayout + condition
└─ NO → ViewThatFits| I need to... | Use this | Not this |
|---|---|---|
| Pick between 2-3 layout variants | | |
| Switch H↔V with animation | | Conditional HStack/VStack |
| Read container size | | |
| Adapt to accessibility text | | Fixed breakpoints |
| Detect compact width | | |
| Detect narrow window on iPad | Geometry + threshold | Size class alone |
| Hide/show sidebar | | Manual column logic |
| Custom layout algorithm | | Nested GeometryReaders |
ViewThatFits {
// First choice: horizontal
HStack {
Image(systemName: "star")
Text("Favorite")
Spacer()
Button("Add") { }
}
// Fallback: vertical
VStack {
HStack {
Image(systemName: "star")
Text("Favorite")
}
Button("Add") { }
}
}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)
}
}@Environment(\.dynamicTypeSize) var dynamicTypeSize
var layout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout())
: AnyLayout(HStackLayout())
}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
}
}
}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
}
}
}// ✅ CORRECT: Constrained GeometryReader
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
.frame(height: 44) // MUST constrain!
Button("Next") { }
}
// ❌ WRONG: Unconstrained (greedy)
VStack {
GeometryReader { geo in
Text("Width: \(geo.size.width)")
}
// Takes all available space, crushes siblings
Button("Next") { }
}| Configuration | Horizontal | Vertical |
|---|---|---|
| Full screen portrait | | |
| Full screen landscape | | |
| 70% Split View | | |
| 50% Split View | | |
| 33% Split View | | |
| Slide Over | | |
| With keyboard | (unchanged) | (unchanged) |
.compact| Before iOS 26 | iOS 26+ |
|---|---|
| Fixed Split View sizes | Free-form drag-to-resize |
| Deprecated |
| No menu bar on iPad | Menu bar via |
| Manual column visibility | |
"Resizing an app should not permanently alter its layout. Be opportunistic about reverting back to the starting state whenever possible."
// iOS 26: Columns automatically show/hide
NavigationSplitView {
Sidebar()
} content: {
ContentList()
} detail: {
DetailView()
}
// No manual columnVisibility management neededUIRequiresFullScreen// ❌ WRONG: Reports device, not window
NotificationCenter.default.addObserver(
forName: UIDevice.orientationDidChangeNotification, ...
)
let orientation = UIDevice.current.orientation
if orientation.isLandscape { ... }onGeometryChange// ❌ WRONG: Returns full screen, not your window
let width = UIScreen.main.bounds.width
if width > 700 { useWideLayout() }// ❌ WRONG: Breaks on new devices, wrong in multitasking
if UIDevice.current.userInterfaceIdiom == .pad {
useWideLayout()
}// ❌ WRONG: GeometryReader is greedy
VStack {
GeometryReader { geo in
Text("Size: \(geo.size)")
}
Button("Next") { } // Crushed
}.frame()onGeometryChange// ❌ WRONG: iPad is .regular in both orientations
var isLandscape: Bool {
horizontalSizeClass == .regular // Always true on iPad!
}if UIDevice.current.userInterfaceIdiom == .phoneonGeometryChange.regularUIRequiresFullScreen = true