--- name: axiom-swiftui-layout-ref description: Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryChange, GeometryReader, size classes, and iOS 26 window APIs license: MIT metadata: version: "1.0.0" --- # 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 ```swift ViewThatFits { // First choice HStack { icon title Spacer() button } // Second choice HStack { icon title button } // Fallback VStack { HStack { icon; title } button } } ``` ### With Axis Constraint ```swift // Only consider horizontal fit ViewThatFits(in: .horizontal) { wideVersion narrowVersion } // Only consider vertical fit ViewThatFits(in: .vertical) { tallVersion shortVersion } ``` ### How It Works 1. Applies `fixedSize()` to each child 2. Measures ideal size against available space 3. Returns first child that fits 4. 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 ```swift 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 ```swift 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 ```swift // 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 ```swift // ❌ 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 ```swift 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 ```swift 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 ```swift // 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 ```swift @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 ```swift // 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 ```swift // 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) ```swift // ✅ 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 ```swift 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 ```swift // 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 ```swift // ❌ 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 ```swift // ❌ 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) ```swift 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 ```swift // ✅ 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: ```swift // iOS 13-16 fallback GeometryReader { geo in content .padding(.horizontal, 20 + geo.safeAreaInsets.leading) } ``` Or conditional compilation: ```swift if #available(iOS 17, *) { content.safeAreaPadding(.horizontal, 20) } else { content.padding(.horizontal, 20) .padding(.leading, safeAreaInsets.leading) } ``` ### Edge-Specific Usage ```swift // 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 ```swift 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 ```swift 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) ```swift // 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 ```swift // 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 ```swift // ❌ 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: ```swift 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: ```swift 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 ```swift struct AdaptiveView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass var body: some View { if horizontalSizeClass == .compact { compactLayout } else { regularLayout } } } ``` ### Size Class Values ```swift 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 ```swift content .environment(\.horizontalSizeClass, .compact) ``` --- ## Dynamic Type Size Environment value for user's preferred text size. ### Reading Dynamic Type ```swift @Environment(\.dynamicTypeSize) var dynamicTypeSize var body: some View { if dynamicTypeSize.isAccessibilitySize { accessibleLayout } else { standardLayout } } ``` ### Size Categories ```swift 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 ```swift @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 ```swift WindowGroup { ContentView() } .windowResizeAnchor(.topLeading) // Resize originates from top-left .windowResizeAnchor(.center) // Resize from center ``` ### Menu Bar Commands (iPad) ```swift @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 ```swift // 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 ```swift @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 ```swift // 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 ```swift 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 ```swift ScrollView { content } .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.y } action: { offset in scrollOffset = offset } ``` ### ScrollGeometry Properties ```swift .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. ```swift // ❌ 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