SwiftUI 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 fixedSize() to each child
-
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
-
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+)
-
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
.safeAreaInset(edge:)
- Adds persistent content that shrinks the safe area:
ScrollView { content } .safeAreaInset(edge: .bottom) { // This REDUCES the safe area, content scrolls under it toolbarButtons .padding() .background(.ultraThinMaterial) }
.ignoresSafeArea()
- Opts out of safe area completely:
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 .compact
.regular
Landscape (small) .compact
.compact
Landscape (Plus/Max) .regular
.compact
iPad:
Configuration Horizontal Vertical
Any full screen .regular
.regular
70% Split View .regular
.regular
50% Split View .regular
.regular
33% Split View .compact
.regular
Slide Over .compact
.regular
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 .id() on items.
-
onAppear/onDisappear fire repeatedly: Views are created and destroyed as you scroll. Don't use these for one-time setup.
-
State resets on recycle: @State in lazy items resets when recycled. Lift state to the model layer.
// ❌ 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 VStack / HStack
No recycling overhead, simpler
Nested in another lazy container VStack (inner) Nested lazy causes layout issues
Need all items measured upfront VStack
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