Skillshub axiom-swiftui-layout-ref
Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryChange, GeometryReader, size classes, and iOS 26 window APIs
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-swiftui-layout-ref" ~/.claude/skills/comeonoliver-skillshub-axiom-swiftui-layout-ref && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-swiftui-layout-ref/SKILL.mdSwiftUI Layout API Reference
Comprehensive API reference for SwiftUI adaptive layout tools. For decision guidance and anti-patterns, see the
axiom-swiftui-layout skill.
Overview
This reference covers all SwiftUI layout APIs for building adaptive interfaces:
- ViewThatFits — Automatic variant selection (iOS 16+)
- AnyLayout — Type-erased animated layout switching (iOS 16+)
- Layout Protocol — Custom layout algorithms (iOS 16+)
- onGeometryChange — Efficient geometry reading (iOS 16+ backported)
- GeometryReader — Layout-phase geometry access (iOS 13+)
- Safe Area Padding — .safeAreaPadding() vs .padding() (iOS 17+)
- Size Classes — Trait-based adaptation
- iOS 26 Window APIs — Free-form windows, menu bar, resize anchors
ViewThatFits
Evaluates child views in order and displays the first one that fits in the available space.
Basic Usage
ViewThatFits { // First choice HStack { icon title Spacer() button } // Second choice HStack { icon title button } // Fallback VStack { HStack { icon; title } button } }
With Axis Constraint
// Only consider horizontal fit ViewThatFits(in: .horizontal) { wideVersion narrowVersion } // Only consider vertical fit ViewThatFits(in: .vertical) { tallVersion shortVersion }
How It Works
- Applies
to each childfixedSize() - Measures ideal size against available space
- Returns first child that fits
- Falls back to last child if none fit
Limitations
- Does not expose which variant was selected
- Cannot animate between variants (use AnyLayout instead)
- Measures all variants (performance consideration for complex views)
AnyLayout
Type-erased layout container enabling animated transitions between layouts.
Basic Usage
struct AdaptiveView: View { @Environment(\.horizontalSizeClass) var sizeClass var layout: AnyLayout { sizeClass == .compact ? AnyLayout(VStackLayout(spacing: 12)) : AnyLayout(HStackLayout(spacing: 20)) } var body: some View { layout { ForEach(items) { item in ItemView(item: item) } } .animation(.default, value: sizeClass) } }
Available Layout Types
AnyLayout(HStackLayout(alignment: .top, spacing: 10)) AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) AnyLayout(ZStackLayout(alignment: .center)) AnyLayout(GridLayout(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10))
Custom Conditions
// Based on Dynamic Type @Environment(\.dynamicTypeSize) var typeSize var layout: AnyLayout { typeSize.isAccessibilitySize ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout()) } // Based on geometry @State private var isWide = true var layout: AnyLayout { isWide ? AnyLayout(HStackLayout()) : AnyLayout(VStackLayout()) }
Why Use Over Conditional Views
// ❌ Loses view identity, no animation if isCompact { VStack { content } } else { HStack { content } } // ✅ Preserves identity, smooth animation let layout = isCompact ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout()) layout { content }
Layout Protocol
Create custom layout containers with full control over positioning.
Basic Custom Layout
struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let sizes = subviews.map { $0.sizeThatFits(.unspecified) } return calculateSize(for: sizes, in: proposal.width ?? .infinity) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var point = bounds.origin var lineHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if point.x + size.width > bounds.maxX { point.x = bounds.origin.x point.y += lineHeight + spacing lineHeight = 0 } subview.place(at: point, proposal: .unspecified) point.x += size.width + spacing lineHeight = max(lineHeight, size.height) } } } // Usage FlowLayout(spacing: 12) { ForEach(tags) { tag in TagView(tag: tag) } }
With Cache
struct CachedLayout: Layout { struct CacheData { var sizes: [CGSize] = [] } func makeCache(subviews: Subviews) -> CacheData { CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) }) } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize { // Use cache.sizes instead of measuring again } }
Layout Values
// Define custom layout value struct Rank: LayoutValueKey { static let defaultValue: Int = 0 } extension View { func rank(_ value: Int) -> some View { layoutValue(key: Rank.self, value: value) } } // Read in layout func placeSubviews(...) { let sorted = subviews.sorted { $0[Rank.self] < $1[Rank.self] } }
onGeometryChange
Efficient geometry reading without layout side effects. Backported to iOS 16+.
Basic Usage
@State private var size: CGSize = .zero var body: some View { content .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in size = newSize } }
Reading Specific Values
// Width only .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.width } action: { width in columnCount = max(1, Int(width / 150)) } // Frame in coordinate space .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .global) } action: { frame in globalFrame = frame } // Aspect ratio .onGeometryChange(for: Bool.self) { proxy in proxy.size.width > proxy.size.height } action: { isWide in self.isWide = isWide }
Coordinate Spaces
// Named coordinate space ScrollView { content .onGeometryChange(for: CGFloat.self) { proxy in proxy.frame(in: .named("scroll")).minY } action: { offset in scrollOffset = offset } } .coordinateSpace(name: "scroll")
Comparison with GeometryReader
| Aspect | onGeometryChange | GeometryReader |
|---|---|---|
| Layout impact | None | Greedy (fills space) |
| When evaluated | After layout | During layout |
| Use case | Side effects | Layout calculations |
| iOS version | 16+ (backported) | 13+ |
GeometryReader
Provides geometry information during layout phase. Use sparingly due to greedy sizing.
Basic Usage (Constrained)
// ✅ Always constrain GeometryReader GeometryReader { proxy in let width = proxy.size.width HStack(spacing: 0) { Rectangle().frame(width: width * 0.3) Rectangle().frame(width: width * 0.7) } } .frame(height: 100) // Required constraint
GeometryProxy Properties
GeometryReader { proxy in // Container size let size = proxy.size // CGSize // Safe area insets let insets = proxy.safeAreaInsets // EdgeInsets // Frame in coordinate space let globalFrame = proxy.frame(in: .global) let localFrame = proxy.frame(in: .local) let namedFrame = proxy.frame(in: .named("container")) }
Common Patterns
// Proportional sizing GeometryReader { geo in VStack { header.frame(height: geo.size.height * 0.2) content.frame(height: geo.size.height * 0.8) } } // Centering with offset GeometryReader { geo in content .position(x: geo.size.width / 2, y: geo.size.height / 2) }
Avoiding Common Mistakes
// ❌ Unconstrained in VStack VStack { GeometryReader { ... } // Takes ALL space Button("Next") { } // Invisible } // ✅ Constrained VStack { GeometryReader { ... } .frame(height: 200) Button("Next") { } } // ❌ Causing layout loops GeometryReader { geo in content .frame(width: geo.size.width) // Can cause infinite loop }
Safe Area Padding
SwiftUI provides two primary approaches for handling spacing around content:
.padding() and .safeAreaPadding(). Understanding when to use each is critical for proper layout on devices with safe areas (notch, Dynamic Island, home indicator).
The Critical Difference
// ❌ WRONG - Ignores safe areas, content hits notch/home indicator ScrollView { content } .padding(.horizontal, 20) // ✅ CORRECT - Respects safe areas, adds padding beyond them ScrollView { content } .safeAreaPadding(.horizontal, 20)
Key insight:
.padding() adds fixed spacing from the view's edges. .safeAreaPadding() adds spacing beyond the safe area insets.
When to Use Each
Use .padding()
when
.padding()- Adding spacing between sibling views within a container
- Creating internal spacing that should be consistent everywhere
- Working with views that already respect safe areas (like List, Form)
- Adding decorative spacing on macOS (no safe area concerns)
VStack(spacing: 0) { header .padding(.horizontal, 16) // ✅ Internal spacing Divider() content .padding(.horizontal, 16) // ✅ Internal spacing }
Use .safeAreaPadding()
when (iOS 17+)
.safeAreaPadding()- Adding margin to full-width content that extends to screen edges
- Implementing edge-to-edge scrolling with proper insets
- Creating custom containers that need safe area awareness
- Working with Liquid Glass or full-screen materials
// ✅ Edge-to-edge list with custom padding List(items) { item in ItemRow(item) } .listStyle(.plain) .safeAreaPadding(.horizontal, 20) // Adds 20pt beyond safe areas // ✅ Full-screen content with proper margins ZStack { Color.blue.ignoresSafeArea() VStack { content } .safeAreaPadding(.all, 16) // Respects notch, home indicator }
Platform Availability
iOS 17+, iPadOS 17+, macOS 14+, axiom-visionOS 1.0+
For earlier iOS versions, use manual safe area handling:
// iOS 13-16 fallback GeometryReader { geo in content .padding(.horizontal, 20 + geo.safeAreaInsets.leading) }
Or conditional compilation:
if #available(iOS 17, *) { content.safeAreaPadding(.horizontal, 20) } else { content.padding(.horizontal, 20) .padding(.leading, safeAreaInsets.leading) }
Edge-Specific Usage
// Top only (below status bar/notch) .safeAreaPadding(.top, 8) // Bottom only (above home indicator) .safeAreaPadding(.bottom, 16) // Horizontal (left/right of safe areas) .safeAreaPadding(.horizontal, 20) // All edges .safeAreaPadding(.all, 16) // Individual edges .safeAreaPadding(EdgeInsets(top: 8, leading: 20, bottom: 16, trailing: 20))
Common Patterns
Edge-to-Edge ScrollView
ScrollView { LazyVStack(spacing: 12) { ForEach(items) { item in ItemCard(item) } } } .safeAreaPadding(.horizontal, 16) // Content inset from edges + safe areas .safeAreaPadding(.vertical, 8)
Full-Screen Background with Safe Content
ZStack { // Background extends edge-to-edge LinearGradient(...) .ignoresSafeArea() // Content respects safe areas + custom padding VStack { header Spacer() content Spacer() footer } .safeAreaPadding(.all, 20) }
Nested Padding (Combined Approach)
// Outer: Safe area padding for device insets VStack(spacing: 0) { content } .safeAreaPadding(.horizontal, 16) // Beyond safe areas // Inner: Regular padding for internal spacing VStack { Text("Title") .padding(.bottom, 8) // Internal spacing Text("Subtitle") }
Decision Tree
Does your content extend to screen edges? ├─ YES → Use .safeAreaPadding() │ ├─ Is it scrollable? → .safeAreaPadding(.horizontal/.vertical) │ └─ Is it full-screen? → .safeAreaPadding(.all) │ └─ NO (contained within a safe container like List/Form) └─ Use .padding() for internal spacing
Visual Debugging
// Visualize safe area padding (iOS 17+) content .safeAreaPadding(.horizontal, 20) .background(.red.opacity(0.2)) // Shows padding area .border(.blue) // Shows content bounds
Migration from Manual Safe Area Handling
// ❌ OLD: Manual calculation (iOS 13-16) GeometryReader { geo in content .padding(.top, geo.safeAreaInsets.top + 16) .padding(.bottom, geo.safeAreaInsets.bottom + 16) .padding(.horizontal, 20) } // ✅ NEW: .safeAreaPadding() (iOS 17+) content .safeAreaPadding(.vertical, 16) .safeAreaPadding(.horizontal, 20)
Related APIs
- Adds persistent content that shrinks the safe area:.safeAreaInset(edge:)
ScrollView { content } .safeAreaInset(edge: .bottom) { // This REDUCES the safe area, content scrolls under it toolbarButtons .padding() .background(.ultraThinMaterial) }
- Opts out of safe area completely:.ignoresSafeArea()
Color.blue .ignoresSafeArea() // Extends to absolute screen edges
Why It Matters
Before iOS 17: Developers had to manually calculate safe area insets with GeometryReader, leading to:
- Verbose code
- Performance overhead (GeometryReader forces extra layout pass)
- Easy mistakes (forgetting to check all edges)
iOS 17+:
.safeAreaPadding() provides:
- Declarative API (matches SwiftUI philosophy)
- Automatic safe area awareness
- Better performance (no extra layout passes)
- Type-safe edge specification
Real-world impact: Using
.padding() instead of .safeAreaPadding() on iPhone 15 Pro causes content to:
- Hit the Dynamic Island (top)
- Overlap the home indicator (bottom)
- Get cut off by screen corners (rounded edges)
Size Classes
Environment values indicating horizontal and vertical size characteristics.
Reading Size Classes
struct AdaptiveView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass var body: some View { if horizontalSizeClass == .compact { compactLayout } else { regularLayout } } }
Size Class Values
enum UserInterfaceSizeClass { case compact // Constrained space case regular // Ample space }
Platform Behavior
iPhone:
| Orientation | Horizontal | Vertical |
|---|---|---|
| Portrait | | |
| Landscape (small) | | |
| Landscape (Plus/Max) | | |
iPad:
| Configuration | Horizontal | Vertical |
|---|---|---|
| Any full screen | | |
| 70% Split View | | |
| 50% Split View | | |
| 33% Split View | | |
| Slide Over | | |
Overriding Size Classes
content .environment(\.horizontalSizeClass, .compact)
Dynamic Type Size
Environment value for user's preferred text size.
Reading Dynamic Type
@Environment(\.dynamicTypeSize) var dynamicTypeSize var body: some View { if dynamicTypeSize.isAccessibilitySize { accessibleLayout } else { standardLayout } }
Size Categories
enum DynamicTypeSize: Comparable { case xSmall case small case medium case large // Default case xLarge case xxLarge case xxxLarge case accessibility1 // isAccessibilitySize = true case accessibility2 case accessibility3 case accessibility4 case accessibility5 }
Scaled Metric
@ScaledMetric var iconSize: CGFloat = 24 @ScaledMetric(relativeTo: .largeTitle) var headerSize: CGFloat = 44 Image(systemName: "star") .frame(width: iconSize, height: iconSize)
iOS 26 Window APIs
Window Resize Anchor
WindowGroup { ContentView() } .windowResizeAnchor(.topLeading) // Resize originates from top-left .windowResizeAnchor(.center) // Resize from center
Menu Bar Commands (iPad)
@main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() } .commands { CommandMenu("View") { Button("Show Sidebar") { showSidebar.toggle() } .keyboardShortcut("s", modifiers: [.command, .option]) Divider() Button("Zoom In") { zoom += 0.1 } .keyboardShortcut("+") Button("Zoom Out") { zoom -= 0.1 } .keyboardShortcut("-") } } } }
NavigationSplitView Column Control
// iOS 26: Automatic column visibility NavigationSplitView { Sidebar() } content: { ContentList() } detail: { DetailView() } // Columns auto-hide/show based on available width // Manual control (when needed) @State private var columnVisibility: NavigationSplitViewVisibility = .all NavigationSplitView(columnVisibility: $columnVisibility) { Sidebar() } detail: { DetailView() }
Scene Phase
@Environment(\.scenePhase) var scenePhase var body: some View { content .onChange(of: scenePhase) { oldPhase, newPhase in switch newPhase { case .active: // Window is visible and interactive case .inactive: // Window is visible but not interactive case .background: // Window is not visible } } }
Coordinate Spaces
Built-in Coordinate Spaces
// Global (screen coordinates) proxy.frame(in: .global) // Local (view's own bounds) proxy.frame(in: .local) // Named (custom) proxy.frame(in: .named("mySpace"))
Creating Named Spaces
ScrollView { content .onGeometryChange(for: CGFloat.self) { proxy in proxy.frame(in: .named("scroll")).minY } action: { offset in scrollOffset = offset } } .coordinateSpace(name: "scroll") // iOS 17+ typed coordinate space extension CoordinateSpaceProtocol where Self == NamedCoordinateSpace { static var scroll: Self { .named("scroll") } }
ScrollView Geometry (iOS 18+)
onScrollGeometryChange
ScrollView { content } .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.y } action: { offset in scrollOffset = offset }
ScrollGeometry Properties
.onScrollGeometryChange(for: ScrollGeometry.self) { $0 } action: { geo in let offset = geo.contentOffset // Current scroll position let size = geo.contentSize // Total content size let visible = geo.visibleRect // Currently visible rect let insets = geo.contentInsets // Content insets }
Lazy Container Gotchas
Recycling Behavior
LazyVStack and LazyHStack create views on demand and recycle them when off-screen. This means:
- View identity matters: If cells flash/disappear during fast scrolling, the view identity is unstable. Use explicit
on items..id() - onAppear/onDisappear fire repeatedly: Views are created and destroyed as you scroll. Don't use these for one-time setup.
- State resets on recycle:
in lazy items resets when recycled. Lift state to the model layer.@State
// ❌ Items flash during fast scroll — unstable identity LazyVStack { ForEach(Array(items.enumerated()), id: \.offset) { index, item in ItemRow(item: item) // Identity changes when array mutates } } // ✅ Stable identity prevents flash/disappear LazyVStack { ForEach(items) { item in // Uses item.id (Identifiable) ItemRow(item: item) } }
When NOT to Use Lazy Containers
| Scenario | Use Instead | Why |
|---|---|---|
| < 50 items | / | No recycling overhead, simpler |
| Nested in another lazy container | (inner) | Nested lazy causes layout issues |
| Need all items measured upfront | | Lazy containers don't know total size |
Resources
WWDC: 2025-208, 2024-10074, 2022-10056
Docs: /swiftui/layout, /swiftui/viewthatfits
Skills: axiom-swiftui-layout, axiom-swiftui-debugging