SwiftUI Layout

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Layout is where SwiftUI differs most from the frameworks it replaces. There are no constraints to solve and no frames to set from outside: every view decides its own size, inside limits its parent offers, and the parent then decides where to put it. Once that negotiation is clear, stacks, grids, frame, Spacer and even custom layout containers all follow from it. This page is written against iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, with FieldNotes keeping a deployment minimum of 26. Most layout APIs here have existed for several releases and need no availability checks at that minimum; the exceptions are ArrangementView, ReservedRegion and the hinge APIs for the foldable iPhone Duo, which are new in iOS 27.1 and iPadOS 27.1 and are guarded accordingly.

This page assumes the view and modifier model from SwiftUI Views and Modifiers: modifiers wrap views, and wrapping order matters. Lists, tables and outline views, which are layout containers with their own behaviour (selection, swipe actions, reordering), have their own page, Lists, Tables and Collections.

How SwiftUI Layout Works: Propose, Choose, Place

Every layout pass is a conversation between a parent and each of its children, in three steps:

  1. The parent proposes a size. The proposal is a ProposedViewSize: an optional width and an optional height. A concrete value means "you have this much room"; 0 asks for the view’s minimum size; .infinity asks for its maximum; nil asks for its ideal size.

  2. The child chooses its own size. It may use the proposal, ignore it, or take part of it. A Text wraps or truncates to fit the proposed width but never grows beyond its content; a Color or a Spacer takes whatever it is offered; an Image without .resizable() always reports its natural pixel size. Whatever the child answers is final: the parent cannot overrule it.

  3. The parent places the child. The parent positions the child inside its own coordinate space, using its alignment. It cannot resize the child at this point, only move it.

Containers apply the same protocol recursively. An HStack given a width first asks each child how flexible it is, offers space to the least flexible children first, divides what remains among the flexible ones (in order of layoutPriority), reports its own size as the union of its children plus spacing, and finally places them side by side. That is why an Image next to a long Text keeps its size while the text wraps: the image is inflexible, so it is served first.

Here is the header row of NoteRow, and the negotiation it triggers inside a list row that is 390 points wide:

HStack(spacing: 8) {
    Image(systemName: notebook.symbolName)   // inflexible: always its symbol size
        .foregroundStyle(.tint)
    Text(note.title)                         // flexible: wraps or truncates to the width it gets
        .font(.headline)
        .lineLimit(1)
    Spacer(minLength: 8)                     // fully flexible: takes whatever is left
    Text(note.createdAt, style: .relative)   // flexible, but should never truncate
        .font(.caption)
        .foregroundStyle(.secondary)
        .fixedSize()                         // "use your ideal size, ignore the proposal"
}

Figure: Layout Negotiation for a NoteRow Header

sequenceDiagram participant Row as List row participant Stack as HStack participant Icon as Image participant Stamp as Text date with fixedSize participant Heading as Text title participant Gap as Spacer Row->>Stack: Propose 390 x nil Stack->>Icon: Propose a share of the width Icon-->>Stack: I am 22 x 20, always Stack->>Stamp: Propose remaining width Stamp-->>Stack: fixedSize, so my ideal 52 x 16 Stack->>Heading: Propose what is left minus spacing Heading-->>Stack: I fit in 250 x 22 on one line Stack->>Gap: Propose the remainder Gap-->>Stack: I take it, at least 8 Stack-->>Row: I am 390 x 22 Row->>Stack: Place at the row origin Stack->>Icon: Place at x 0, centred vertically Stack->>Heading: Place after the icon plus 8 Stack->>Gap: Place after the title Stack->>Stamp: Place at the trailing edge

Two modifiers tune the negotiation directly. .layoutPriority(_:) tells a stack which flexible child to serve first (the default is 0), and .fixedSize() makes a view answer with its ideal size regardless of the proposal, which is how the relative date above avoids ever being truncated to "3 m…".

HStack {
    Text(note.title)
        .lineLimit(1)
        .layoutPriority(1)       // gets its space before the notebook name
    Text(notebook.name)
        .lineLimit(1)
        .foregroundStyle(.secondary)
}

Stacks and Lazy Stacks

HStack, VStack and ZStack are the workhorse containers. Each takes an alignment (the cross axis for the two linear stacks, both axes for ZStack) and, for the linear stacks, a spacing; omitting spacing lets SwiftUI choose platform-appropriate spacing between each pair of children. NotebookTile, the tile used by the notebook grid below, uses all three:

struct NotebookTile: View {
    let notebook: Notebook
    let noteCount: Int

    var body: some View {
        ZStack(alignment: .topTrailing) {
            VStack(alignment: .leading, spacing: 6) {
                Image(systemName: notebook.symbolName)
                    .font(.title2)
                    .foregroundStyle(.tint)
                Text(notebook.name)
                    .font(.headline)
                HStack(spacing: 4) {
                    Image(systemName: "note.text")
                    Text("\(noteCount) notes")
                }
                .font(.caption)
                .foregroundStyle(.secondary)
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.regularMaterial, in: .rect(cornerRadius: 14))

            if noteCount == 0 {
                Text("Empty")
                    .font(.caption2.bold())
                    .padding(6)
            }
        }
    }
}

Lazy Stacks

A VStack creates and measures all of its children up front, which is fine for a dozen views and wasteful for a thousand. Inside a ScrollView, LazyVStack and LazyHStack create children only as they approach the visible region and discard them once they scroll away. The FieldNotes timeline, which shows every note grouped by notebook with sticky headers, is a lazy stack:

struct NoteTimeline: View {
    @Environment(NoteStore.self) private var store

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 12, pinnedViews: [.sectionHeaders]) {
                ForEach(store.notebooks) { notebook in
                    Section {
                        ForEach(store.notes.filter { $0.notebookID == notebook.id }) { note in
                            NoteRow(note: note)
                                .padding(.horizontal)
                        }
                    } header: {
                        Label(notebook.name, systemImage: notebook.symbolName)
                            .font(.headline)
                            .padding(.horizontal)
                            .padding(.vertical, 6)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(.bar)
                    }
                }
            }
        }
    }
}

Laziness has consequences that Apple’s WWDC26 session Dive into lazy stacks and scrolling with SwiftUI spells out, and they shape how FieldNotes builds its scrolling screens:

  • Off-screen sizes are estimates. A lazy stack has not measured the rows it has not loaded, so it estimates their height from the rows it has seen. The total content height, and therefore the scroll offset, can shift as more rows are measured — for example after rotating an iPhone, when rows become shorter.

  • The cross-axis size comes from the first child. A LazyVStack cannot know its widest child without loading all of them, so its ideal width is that of its first subview; a LazyHStack inside a vertical scroll view takes the height of its first subview. Give children in a horizontal lazy stack a fixed height (a line limit on text, a fixed frame on images) so later, taller items are not clipped.

  • Don’t move rows outside their laid-out frame. A .scrollTransition or .offset that pushes a row into the visible region does not make the stack load it; it loads rows based on their original position.

  • Prefer relative visibility to absolute offsets. Because the content offset is estimated, logic such as "hide this button after scrolling 100 points" can flicker; asking which rows are visible is stable. The scrolling section below shows both.

From the 27 releases, stacks, grids and custom layouts (not just List) also support drag-to-reorder and swipe actions; those are covered on Lists, Tables and Collections.

Grids and Lazy Grids

Grid lays out a fixed set of views in rows and columns, where each column is as wide as its widest cell and each row as tall as its tallest. It measures every cell, so it suits small, two-dimensional content such as the metadata block at the top of NoteDetail:

Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
    GridRow {
        Text("Created")
            .foregroundStyle(.secondary)
            .gridColumnAlignment(.trailing)       // the whole label column aligns trailing
        Text(note.createdAt, format: .dateTime)
    }
    GridRow {
        Text("Notebook")
            .foregroundStyle(.secondary)
        Label(notebook.name, systemImage: notebook.symbolName)
    }
    Divider()                                    // a view outside a GridRow spans every column
        .gridCellUnsizedAxes(.horizontal)        // ...without widening the grid
    GridRow {
        Text("Tags")
            .foregroundStyle(.secondary)
        TagStrip(tags: note.tags)
            .gridCellColumns(1)
    }
}

LazyVGrid and LazyHGrid are the lazy, scrolling counterparts. You describe the columns (or rows) with GridItems — .fixed, .flexible, or .adaptive, which fits as many columns as the width allows — and the grid fills them with as many items as the data has, creating them only as they scroll into view. The notebook grid on iPad and Mac uses an adaptive column, so it shows two tiles side by side on a narrow window and five on a wide one without any size-class code:

struct NotebookGrid: View {
    @Environment(NoteStore.self) private var store
    private let columns = [GridItem(.adaptive(minimum: 150, maximum: 240), spacing: 16)]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
                ForEach(store.notebooks) { notebook in
                    NotebookTile(
                        notebook: notebook,
                        noteCount: store.notes.count(where: { $0.notebookID == notebook.id })
                    )
                }
            }
            .padding()
        }
    }
}

Choosing a container is mostly a question of how much content there is and whether it has two dimensions:

Container Use it for FieldNotes

HStack / VStack / ZStack

A handful of views on one axis, or layered.

NoteRow, NotebookTile

LazyVStack / LazyHStack in a ScrollView

Long or unbounded single-axis content with a custom look.

NoteTimeline, the pinned-notes carousel

Grid / GridRow

Small two-dimensional content whose columns must line up.

NoteDetail metadata

LazyVGrid / LazyHGrid

Long two-dimensional content with uniform items.

NotebookGrid

List / Table

Rows with system selection, editing, swipe actions and platform styling.

NoteList; see Lists, Tables and Collections

frame, padding and Spacer

These three modifiers do most of the fine-tuning in real layouts, and all three are wrappers that take part in the negotiation like any other view:

  • padding proposes the offered size minus its insets to its child, then reports the child’s size plus the insets. Without arguments it uses a platform-appropriate default on every edge.

  • frame(width:height:alignment:) creates a fixed-size box and proposes exactly that size to its child. The child may still choose to be smaller, in which case alignment positions it inside the box.

  • frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) creates a flexible box. maxWidth: .infinity is the idiom for "fill the available width", and pairing it with an alignment is the SwiftUI replacement for a leading-aligned label that stretches.

  • Spacer is a flexible, empty view that expands along the axis of its stack, pushing its siblings apart; Spacer(minLength:) keeps at least that much room even when space runs short.

HStack(alignment: .top, spacing: 12) {
    Image(systemName: notebook.symbolName)
        .font(.title3)
        .frame(width: 28, height: 28)                     // fixed box: icons line up in a column
    VStack(alignment: .leading) {
        Text(note.title).font(.headline)
        Text(note.body).lineLimit(3)
    }
    .frame(maxWidth: .infinity, alignment: .leading)      // fill the width, content leading
}
.padding(.vertical, 8)
.padding(.horizontal)

A frame does not stretch its content; it only offers more room. Text("Pinned").frame(width: 200) is still a short text, centred inside a 200-point box. To fill the box, the content must itself be flexible — a Color, a shape, a .resizable() image — or be wrapped in a flexible frame as above.

Alignment and Alignment Guides

Stacks align their children along the cross axis using alignment guides: every view exposes named guides (.leading, .center, .firstTextBaseline, …), and the stack lines up the same guide of every child. You can override where a view reports a guide with .alignmentGuide(_:computeValue:), and you can define entirely new guides with a custom AlignmentID.

The notebook header in the sidebar shows an icon next to a two-line block (name and note count). Centring the icon on the whole block looks wrong, and .firstTextBaseline puts it too low for a large symbol. A custom vertical alignment centres the icon on the first line only:

extension VerticalAlignment {
    private enum NotebookTitleCenter: AlignmentID {
        static func defaultValue(in dimensions: ViewDimensions) -> CGFloat {
            dimensions[VerticalAlignment.center]
        }
    }

    static let notebookTitleCenter = VerticalAlignment(NotebookTitleCenter.self)
}

struct NotebookHeader: View {
    let notebook: Notebook
    let noteCount: Int

    var body: some View {
        HStack(alignment: .notebookTitleCenter, spacing: 10) {
            Image(systemName: notebook.symbolName)
                .font(.title)                               // uses the default: its own centre
            VStack(alignment: .leading, spacing: 2) {
                Text(notebook.name)
                    .font(.headline)
                    .alignmentGuide(.notebookTitleCenter) { d in d[VerticalAlignment.center] }
                Text("\(noteCount) notes")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Because the VStack does not set a value for the custom guide itself, it passes up the value from the child that does, so the stack’s "title centre" is the centre of the name line, and the HStack lines the icon’s centre up with it. The same technique aligns labels across unrelated stacks (a custom HorizontalAlignment shared by a column of form rows) without a Grid.

Reading Geometry

Sometimes a view needs to know a size: a carousel card should be one third of the screen, a toolbar should collapse when its container narrows. SwiftUI offers three tools, from most to least preferred.

containerRelativeFrame

containerRelativeFrame(_:alignment:) sizes a view relative to its nearest container — the window or screen, a NavigationSplitView column, a NavigationStack, a tab, or a scroll view — minus that container’s safe area. The count:span:spacing: variant divides the container into equal tracks, which is exactly what a paging carousel needs. The pinned-notes carousel at the top of NoteList shows one card per page on iPhone and three on iPad:

struct PinnedCarousel: View {
    let pinned: [Note]
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 12) {
                ForEach(pinned) { note in
                    NoteRow(note: note)
                        .padding()
                        .frame(height: 120, alignment: .topLeading)
                        .background(.regularMaterial, in: .rect(cornerRadius: 14))
                        .containerRelativeFrame(
                            .horizontal,
                            count: sizeClass == .compact ? 1 : 3,
                            span: 1,
                            spacing: 12
                        )
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .contentMargins(.horizontal, 16, for: .scrollContent)
        .scrollIndicators(.hidden)
    }
}

Apps built with the 27 SDKs also get a fix here: containerRelativeFrame inside a ScrollView now applies safe-area insets on the non-scrolling axis as well, so a vertically sized card in a horizontal scroll view no longer extends under the navigation bar or home indicator.

onGeometryChange

onGeometryChange(for:of:action:) reads the geometry of the view it is attached to without changing its layout. You provide two closures: one that turns the GeometryProxy into a small Equatable value, and one that runs only when that value changes. Converting to the least information you need (a Bool rather than a CGSize) keeps updates rare. NoteEditor uses it to decide whether its tag editor sits beside or below the text:

struct NoteEditor: View {
    let noteID: Note.ID
    @Environment(NoteStore.self) private var store
    @State private var isWide = false

    var body: some View {
        let note = store.notes.first { $0.id == noteID }
        let layout = isWide ? AnyLayout(HStackLayout(alignment: .top)) : AnyLayout(VStackLayout())
        layout {
            TextEditor(text: .constant(note?.body ?? ""))    // editing is wired to state on the next page
            TagStrip(tags: note?.tags ?? [])
                .frame(maxWidth: isWide ? 220 : .infinity, alignment: .leading)
        }
        .onGeometryChange(for: Bool.self) { proxy in
            proxy.size.width > 600
        } action: { newValue in
            isWide = newValue
        }
    }
}

GeometryReader

GeometryReader is the oldest tool and the bluntest. It is a container that accepts all the space it is offered, places its content at its top-leading corner, and passes a GeometryProxy to its content closure. Used as the main content of a screen, it tends to break layouts, because it is greedy and does not size to its content. It is still the right tool when the drawing itself depends on the size, and it is safe inside a background or overlay, where it simply matches the size of the view it decorates. The word-goal bar under NoteEditor is such a case:

Capsule()
    .fill(.quaternary)
    .frame(height: 6)
    .overlay(alignment: .leading) {
        GeometryReader { proxy in
            Capsule()
                .fill(.tint)
                .frame(width: proxy.size.width * min(progress, 1))
        }
    }
    .accessibilityElement()
    .accessibilityLabel("Word goal")
    .accessibilityValue(Text(progress, format: .percent))
Tool Affects layout? Reach for it when

containerRelativeFrame

Yes: it sets the view’s size from the container’s.

A view should be a fraction of the screen, column or scroll view.

onGeometryChange

No: it only reports.

You need to react to a size or position, typically by updating state.

GeometryReader

Yes: it fills all offered space.

Drawing depends on size, ideally inside background or overlay.

ViewThatFits

ViewThatFits takes several alternatives, in order of preference, and shows the first one whose ideal size fits the proposal on the constrained axes. It replaces a lot of manual size-class and geometry code for the common case of "the full version if there is room, a compact one otherwise". The action bar at the bottom of NoteDetail shows labelled buttons on a wide screen, icon-only buttons on a narrow one, and a single menu on the narrowest, reusing the same buttons each time:

struct NoteActions: View {
    let note: Note
    @Environment(NoteStore.self) private var store

    var body: some View {
        ViewThatFits(in: .horizontal) {
            HStack { buttons }
            HStack { buttons }.labelStyle(.iconOnly)
            Menu("Actions", systemImage: "ellipsis.circle") { buttons }
        }
    }

    @ContentBuilder
    private var buttons: some View {
        Button("Pin", systemImage: note.isPinned ? "pin.slash" : "pin") { store.togglePin(note) }
        ShareLink(item: note.body)
        Button("Delete", systemImage: "trash", role: .destructive) { store.delete(note) }
    }
}

ViewThatFits measures the alternatives but shows only one, so each keeps its own identity: switching between them re-creates the chosen view. Keep the alternatives stateless, as above, and hold any state in the parent.

Custom Layouts: a Radial Tag Layout

When no built-in container fits, conform a type to the Layout protocol and implement the same two steps SwiftUI’s own containers do: sizeThatFits(proposal:subviews:cache:) answers "how big are you for this proposal?", and placeSubviews(in:proposal:subviews:cache:) positions every subview in the bounds the parent finally granted. Subviews arrive as LayoutSubview proxies, which you can ask for their size under any proposal and then place(at:anchor:proposal:); you never touch the views themselves.

On iPad and Mac, NoteDetail shows a note’s tags arranged in a circle around the note’s notebook icon. The layout, adapted from Apple’s WWDC22 custom-layout sample, lives in FieldNotesUI:

import SwiftUI

public struct RadialTagLayout: Layout {
    public init() {}

    public func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        // Take the proposed square, or a sensible default when asked for an ideal size.
        let fallback: CGFloat = 240
        let width = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? fallback
        let height = proposal.height.flatMap { $0.isFinite ? $0 : nil } ?? fallback
        let side = min(width, height)
        return CGSize(width: side, height: side)
    }

    public func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        guard !subviews.isEmpty else { return }

        // Measure every chip at its ideal size, and keep the largest one inside the bounds.
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let largest = sizes.reduce(CGSize.zero) {
            CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height))
        }
        let radius = max(0, min(bounds.width - largest.width, bounds.height - largest.height) / 2)
        let step = 2 * Double.pi / Double(subviews.count)

        for (index, subview) in subviews.enumerated() {
            let angle = step * Double(index) - Double.pi / 2      // start at 12 o'clock
            let point = CGPoint(
                x: bounds.midX + radius * cos(angle),
                y: bounds.midY + radius * sin(angle)
            )
            subview.place(at: point, anchor: .center, proposal: .unspecified)
        }
    }
}

A Layout is used exactly like a stack, because Layout types can be called with a content closure:

ZStack {
    Image(systemName: notebook.symbolName)
        .font(.largeTitle)
        .foregroundStyle(.tint)
    RadialTagLayout {
        ForEach(note.tags, id: \.self) { tag in
            TagChip(tag)
        }
    }
}
.frame(width: 260, height: 260)

A few protocol features are worth knowing before writing a bigger layout: a cache type (instead of Void) stores measurements between the two calls; LayoutValueKey with .layoutValue(key:value:) lets a subview pass a custom value, such as a rank or weight, to its container; and spacing on each subview reports the preferred distance to its neighbours, so a custom container can respect platform spacing the way stacks do.

Switching Layouts with AnyLayout

AnyLayout is a type-erased Layout. Choosing between two layouts with it — rather than with an if/else around two different containers — keeps the same subviews with the same identity and state, so SwiftUI can animate each chip from its old position to its new one. The built-in stacks have Layout versions for exactly this purpose: HStackLayout, VStackLayout, ZStackLayout and GridLayout.

struct AdaptiveTagChips: View {
    let tags: [String]
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        let layout = sizeClass == .regular && tags.count > 3
            ? AnyLayout(RadialTagLayout())
            : AnyLayout(HStackLayout(spacing: 6))

        layout {
            ForEach(tags, id: \.self) { tag in
                TagChip(tag)
            }
        }
        .animation(.smooth, value: sizeClass)
    }
}

Figure: Stack, Grid and Custom Radial Layout

Three panels comparing how FieldNotes arranges the same kind of content. Left, an HStack places tag chips in one row, left to right. Middle, a LazyVGrid with adaptive columns places notebook tiles in rows that wrap. Right, the custom RadialTagLayout places tag chips evenly around a circle centred on the notebook icon.

Size Classes and Adaptive Layout

A size class is a coarse description of the space available: .compact or .regular, separately for width and height, read from the environment as \.horizontalSizeClass and \.verticalSizeClass. Size classes change at run time — when an iPhone rotates, or when an iPad window is resized or placed in a narrow slot — and SwiftUI re-evaluates every view that reads them.

Situation Width Height

iPhone, portrait

compact

regular

iPhone, landscape (most models)

compact

compact

iPad, full screen

regular

regular

iPad, a narrow window or slot

compact

regular

FieldNotes uses the width class for structural decisions only — one card per carousel page or three, a radial tag cloud or a row — and leaves fine-grained adaptation to tools that look at the actual space: adaptive grid columns, ViewThatFits and onGeometryChange. That split matters most on iPadOS and macOS, where windows can be any size and a size class alone is too coarse. The Human Interface Guidelines' Layout page lists the size classes of every device.

struct NoteDetailLayout: View {
    let note: Note
    let notebook: Notebook
    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        let layout = sizeClass == .compact
            ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16))
            : AnyLayout(HStackLayout(alignment: .top, spacing: 24))

        layout {
            NoteEditor(noteID: note.id)
            NoteMetadata(note: note, notebook: notebook)     // the Grid shown earlier
                .frame(maxWidth: sizeClass == .compact ? .infinity : 280)
        }
        .padding()
    }
}

Multiplatform decisions that are not about space — a Mac-only Settings scene, a watch-only layout — belong in #if os(…​) blocks and separate views, as described on One Codebase for iOS, iPadOS, macOS, watchOS and visionOS.

Scrolling: Position and Geometry

A ScrollView proposes an unlimited size along its scrolling axis, so its content takes its ideal length there, and it scrolls whatever does not fit. Two families of modifiers let code take part in scrolling.

Controlling the Position with ScrollPosition

scrollPosition(_:anchor:) binds a scroll view to a ScrollPosition value. The value can scroll to a view by identity, to an edge, or to an offset, and it is kept up to date as the user scrolls. For identity-based positions, mark the container that holds the scroll targets with .scrollTargetLayout(); its ForEach supplies the IDs. A later version of NoteTimeline uses it for a "Jump to pinned" button and a "Back to top" button:

struct NoteTimeline: View {
    @Environment(NoteStore.self) private var store
    @State private var position = ScrollPosition(idType: Note.ID.self)

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 12) {
                ForEach(store.notes) { note in
                    NoteRow(note: note)
                }
            }
            .scrollTargetLayout()
        }
        .scrollPosition($position)
        .toolbar {
            Button("Jump to Pinned", systemImage: "pin") {
                if let pinned = store.notes.first(where: \.isPinned) {
                    withAnimation { position.scrollTo(id: pinned.id, anchor: .top) }
                }
            }
            Button("Back to Top", systemImage: "arrow.up") {
                withAnimation { position.scrollTo(edge: .top) }
            }
        }
    }
}

As the user scrolls, position.viewID(type: Note.ID.self) reports the top-most visible note, which FieldNotes stores to restore the timeline where the user left it. In apps built with the 27 SDKs, tapping the status bar to scroll to the top also updates a bound ScrollPosition correctly; earlier SDKs could leave it stale.

Reacting to Scroll Geometry

onScrollGeometryChange(for:of:action:) works like onGeometryChange but for the scroll view’s own geometry: content offset, content size, container size and insets, delivered as a ScrollGeometry. The transform closure reduces it to the value you care about, and the action receives the old and new values when it changes. Here it drives the visibility of the floating capture button, which hides once the user has scrolled more than a screen:

ScrollView {
    // ... the timeline ...
}
.onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y + geometry.contentInsets.top > geometry.containerSize.height
} action: { _, isScrolledFar in
    withAnimation { showsCaptureButton = !isScrolledFar }
}

Because the timeline is a lazy stack, its content offset is partly estimated (see Lazy Stacks above), so a threshold on the absolute offset can shift as rows are measured. When the rule is really about content ("hide the button once the first notes are off screen"), onScrollTargetVisibilityChange(idType:threshold:_:) is steadier, because it reports which scroll targets are visible rather than an offset:

ScrollView {
    LazyVStack {
        ForEach(store.notes) { NoteRow(note: $0) }
    }
    .scrollTargetLayout()
}
.onScrollTargetVisibilityChange(idType: Note.ID.self, threshold: 0.8) { visibleIDs in
    let firstIDs = store.notes.prefix(3).map(\.id)
    withAnimation { showsCaptureButton = visibleIDs.contains(where: firstIDs.contains) }
}

iPhone Duo: ArrangementView and ReservedRegion (New in iOS 27.1)

iOS 27.1 ships alongside iPhone Duo, Apple’s first foldable iPhone, and adds layout APIs for screens that can fold, have a hinge, or have hardware cut into them. They are available on iOS 27.1 and iPadOS 27.1 only — not macOS, watchOS or visionOS — so the FieldNotes code below sits inside #if os(iOS) and an if #available(iOS 27.1, *) check, with the existing layout as the fallback.

ArrangementView

ArrangementView holds a primary and a secondary view and chooses how to arrange them from the context: available size, size class and hardware features such as the fold. Its style decides the family of arrangements:

  • .split (what .automatic resolves to) places the two side by side along one or more axes and adapts the axis to the space; .split.axes(_:) restricts which axes it may use, and .splitArrangementLayoutRatio(_:) sets a view’s preferred share of the space.

  • .overlay layers the primary view over the secondary one, as for player controls over video. When the environment changes — for example, when a foldable device is folded or opened — it can transition to a side-by-side layout; .overlay.axes(_:) restricts the axes it may use for that.

.automatic is the default style.

FieldNotes uses a split arrangement in NoteDetail: the editor is primary, and a reference panel with the note’s photos and location is secondary. Apple describes the split style as adapting its axis to the available size and size class, so FieldNotes does not decide the arrangement itself: the same code yields one layout in a narrow space and another when iPhone Duo is opened, and the fold is one of the inputs SwiftUI takes into account.

struct NoteDetail: View {
    let note: Note

    var body: some View {
        #if os(iOS)
        if #available(iOS 27.1, *) {
            ArrangementView {
                NoteEditor(noteID: note.id)
                    .splitArrangementLayoutRatio(0.6)
            } secondary: {
                NoteReferencePanel(note: note)
            }
            .arrangementViewStyle(.split)
        } else {
            NoteEditor(noteID: note.id)
        }
        #else
        NoteEditor(noteID: note.id)
        #endif
    }
}

@available(iOS 27.1, *)
struct NoteReferencePanel: View {
    let note: Note
    @Environment(\.splitArrangementAxis) private var axis   // nil when not inside a split arrangement

    var body: some View {
        let layout = axis == .horizontal
            ? AnyLayout(VStackLayout(spacing: 12))
            : AnyLayout(HStackLayout(spacing: 12))
        layout {
            NotePhotos(note: note)
            NoteLocationMap(note: note)
        }
    }
}

The secondary panel reads \.splitArrangementAxis to flip its own layout: when the arrangement splits horizontally (two columns), the panel stacks its photos above the map; when it splits vertically, it puts them side by side.

ReservedRegion

A ReservedRegion describes part of a view’s coordinate space that hardware reserves. Its kind is either .occlusion (something covers the screen, such as a camera housing) or .division (something splits it, such as the hinge). Each region has a frame (including margins for interactive content), an isActive flag and a stable id. You query the regions that intersect a view from a GeometryProxy with reservedRegions(kind:options:layoutDirectionBehavior:); by default the frames are mirrored for right-to-left languages, so they line up with your own mirrored layout.

CaptureButton, the floating button that starts a new note from the camera, must never sit on the fold, where it is hard to press and visually split in two. NoteList moves it above the hinge when the hinge runs horizontally across the button’s corner:

#if os(iOS)
@available(iOS 27.1, *)
struct CaptureOverlay: View {
    var startCapture: () -> Void

    var body: some View {
        GeometryReader { proxy in
            let hinge = proxy.reservedRegions(kind: .division).first(where: \.isActive)
            let liftAboveHinge: CGFloat = {
                guard let hinge, hinge.frame.width > hinge.frame.height else { return 0 }
                return max(0, proxy.size.height - hinge.frame.minY)
            }()

            CaptureButton(action: startCapture)
                .padding(16)
                .padding(.bottom, liftAboveHinge)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
        }
    }
}
#endif

A custom Layout can take the same regions as input and avoid them for every subview, which is the pattern Apple’s own documentation shows (RegionAvoidingLayout(regions: proxy.reservedRegions(kind: .occlusion))). For the hinge’s state rather than its position, onHingeChange(isEnabled:_:) delivers a DeviceHingeContext whose hinge is a DeviceHinge with an angle and a status (.closed, .partiallyOpen, .fullyOpen); on other devices the hinge is nil:

@available(iOS 27.1, *)
struct HingeAwareEditor: View {
    let note: Note
    @State private var hinge: DeviceHinge?

    var body: some View {
        NoteEditor(noteID: note.id)
            .onHingeChange { _, newContext in
                hinge = newContext.hinge
            }
    }
}

Designing for the fold — which content deserves each half, and how the app should behave when it is half open — is a design question as much as a layout one; see Human Interface Guidelines in Practice and iPadOS: Windows, Multitasking, Pointer and Apple Pencil for the multi-pane side of the same topic.

What Comes Next

With views, modifiers and layout in place, the next step is data: State and Data Flow with Observation explains how @State, @Binding, @Observable and the environment drive the updates that trigger every layout pass described here. For system list and table containers see Lists, Tables and Collections, and for animating the layout changes that AnyLayout makes possible see Drawing, Animation and Gestures.

References