Reference — Complete SwiftUI adaptive layout API guide covering ViewThatFits, AnyLayout, Layout protocol, onGeometryChange, GeometryReader, size classes, and iOS 26 window APIs
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Comprehensive API reference for SwiftUI adaptive layout tools. For decision guidance and anti-patterns, see the swiftui-layout skill.
This reference covers all SwiftUI layout APIs for building adaptive interfaces:
Evaluates child views in order and displays the first one that fits in the available space.
ViewThatFits {
// First choice
HStack {
icon
title
Spacer()
button
}
// Second choice
HStack {
icon
title
button
}
// Fallback
VStack {
HStack { icon; title }
button
}
}
// Only consider horizontal fit
ViewThatFits(in: .horizontal) {
wideVersion
narrowVersion
}
// Only consider vertical fit
ViewThatFits(in: .vertical) {
tallVersion
shortVersion
}
fixedSize() to each childType-erased layout container enabling animated transitions between layouts.
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)
}
}
AnyLayout(HStackLayout(alignment: .top, spacing: 10))
AnyLayout(VStackLayout(alignment: .leading, spacing: 8))
AnyLayout(ZStackLayout(alignment: .center))
AnyLayout(GridLayout(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10))
// 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())
}
// ❌ 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 }
Create custom layout containers with full control over positioning.
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)
}
}
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
}
}
// 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] }
}
Efficient geometry reading without layout side effects. Backported to iOS 16+.
@State private var size: CGSize = .zero
var body: some View {
content
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newSize in
size = newSize
}
}
// 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
}
// 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")
| 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+ |
Provides geometry information during layout phase. Use sparingly due to greedy sizing.
// ✅ 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
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"))
}
// 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)
}
// ❌ 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
}
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).
// ❌ 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.
.padding() whenVStack(spacing: 0) {
header
.padding(.horizontal, 16) // ✅ Internal spacing
Divider()
content
.padding(.horizontal, 16) // ✅ Internal spacing
}
.safeAreaPadding() when (iOS 17+)// ✅ 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
}
iOS 17+, iPadOS 17+, macOS 14+, 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)
}
// 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))
ScrollView {
LazyVStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item)
}
}
}
.safeAreaPadding(.horizontal, 16) // Content inset from edges + safe areas
.safeAreaPadding(.vertical, 8)
ZStack {
// Background extends edge-to-edge
LinearGradient(...)
.ignoresSafeArea()
// Content respects safe areas + custom padding
VStack {
header
Spacer()
content
Spacer()
footer
}
.safeAreaPadding(.all, 20)
}
// 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")
}
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
// Visualize safe area padding (iOS 17+)
content
.safeAreaPadding(.horizontal, 20)
.background(.red.opacity(0.2)) // Shows padding area
.border(.blue) // Shows content bounds
// ❌ 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)
.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
Before iOS 17: Developers had to manually calculate safe area insets with GeometryReader, leading to:
iOS 17+: .safeAreaPadding() provides:
Real-world impact: Using .padding() instead of .safeAreaPadding() on iPhone 15 Pro causes content to:
Environment values indicating horizontal and vertical size characteristics.
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
if horizontalSizeClass == .compact {
compactLayout
} else {
regularLayout
}
}
}
enum UserInterfaceSizeClass {
case compact // Constrained space
case regular // Ample space
}
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 |
content
.environment(\.horizontalSizeClass, .compact)
Environment value for user's preferred text size.
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
accessibleLayout
} else {
standardLayout
}
}
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
}
@ScaledMetric var iconSize: CGFloat = 24
@ScaledMetric(relativeTo: .largeTitle) var headerSize: CGFloat = 44
Image(systemName: "star")
.frame(width: iconSize, height: iconSize)
WindowGroup {
ContentView()
}
.windowResizeAnchor(.topLeading) // Resize originates from top-left
.windowResizeAnchor(.center) // Resize from center
@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("-")
}
}
}
}
// 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()
}
@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
}
}
}
// Global (screen coordinates)
proxy.frame(in: .global)
// Local (view's own bounds)
proxy.frame(in: .local)
// Named (custom)
proxy.frame(in: .named("mySpace"))
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 {
content
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { offset in
scrollOffset = offset
}
.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
}