---
name: swiftui-layout-components
description: "SwiftUI layouts: stacks, lazy stacks, LazyVGrid/LazyHGrid, List sections, swipe actions, ScrollView with ScrollPosition, validated Form, Toggle, Picker, Slider, .searchable, toast overlays. Use when building data-driven layouts, collection views, settings screens, search or transient overlay UI, or choosing between stacks, grids, lists and scroll views. Not for architecture, state or navigation."
metadata:
  source: multi-agent-pipeline
---

# SwiftUI Layout Components

Which container you pick decides how much work SwiftUI does up front, how rows
are reused, and what the system gives you for free (selection, swipe actions,
accessibility). Examples assume Swift 6.3 and iOS 26; anything not marked with
a newer version also runs on iOS 17.

App structure and state ownership live in `swiftui-patterns`; navigation
stacks, links and routing live in `swiftui-navigation`.

## Stacks: eager or lazy

| Container | Builds children | Use for |
|-----------|-----------------|---------|
| `VStack`, `HStack`, `ZStack` | All at once | Small, fixed content: headers, toolbars, short forms |
| `LazyVStack`, `LazyHStack` inside `ScrollView` | As they scroll into view | Long or unknown-length content: feeds, chat, search results |

A plain stack of a thousand rows builds a thousand views before the first
frame. A lazy stack for three labels adds bookkeeping for nothing.

## Grids

`LazyVGrid` suits dense visual choice: icon pickers, photo galleries, template
tiles.

- `.adaptive` columns let the column count follow the available width.
- Several `.flexible` columns give a fixed count, such as a three-up gallery.

```swift
struct StickerPicker: View {
    let stickers: [Sticker]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 96, maximum: 1024))]) {
                ForEach(stickers) { sticker in
                    StickerTile(sticker: sticker)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
            .padding()
        }
    }
}
```

A fixed three-column gallery passes
`Array(repeating: GridItem(.flexible(minimum: 90), spacing: 2), count: 3)` as
the columns and `spacing: 2` to `LazyVGrid(columns:spacing:)`.

Give cells their shape with `.aspectRatio`. Do not put a `GeometryReader` in a
lazy container: it measures eagerly and undoes the laziness. When a cell really
needs its size, read it with `.onGeometryChange(for:of:action:)`, which Apple
documents as available from iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0 and
visionOS 1.0 (back-deployed; build with the Xcode 16 SDK or later).

Column strategies, sectioned grids, selection and performance:
[grids reference](references/grids.md).

## Lists

`List` is the right container for feeds and settings rows: it reuses rows and
brings selection, swipe actions and accessibility along.

```swift
struct AccountSettings: View {
    var body: some View {
        List {
            Section("Preferences") {
                NavigationLink("Units", value: SettingsPage.units)
                NavigationLink("Appearance", value: SettingsPage.appearance)
            }
            Section("Session") {
                Button("Log Out", role: .destructive) { logOut() }
            }
        }
        .listStyle(.insetGrouped)
    }

    private func logOut() {}
}
```

- Feeds read best as `.listStyle(.plain)`; settings screens as `.insetGrouped`.
- `.scrollContentBackground(.hidden)` plus your own background for themed screens.
- `.listRowInsets(_:)` and `.listRowSeparator(.hidden)` adjust spacing and dividers.
- `ScrollPosition` (iOS 18) with `.scrollPosition($position)` scrolls to the top or to an id.
- `.refreshable { await reload() }` adds pull to refresh.
- `.contentShape(Rectangle())` makes a whole row tappable, not only its text.
- iOS 26: `.scrollEdgeEffectStyle(.soft, for: .top)` fades content under the top bar.

### Swipe actions

Row swipe actions belong to `List` rows. Attach
`.swipeActions(edge:allowsFullSwipe:content:)` to the row view inside
`ForEach`. The trailing edge is the default; add a second modifier for leading
actions.

```swift
List {
    ForEach(inbox) { note in
        NoteRow(note: note)
            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                Button("Delete", systemImage: "trash", role: .destructive) {
                    store.delete(note)
                }
                Button("Snooze", systemImage: "clock") { store.snooze(note) }
                    .tint(.indigo)
            }
            .swipeActions(edge: .leading) {
                Button(note.isPinned ? "Unpin" : "Pin", systemImage: "pin") {
                    store.togglePin(note)
                }
                .tint(.orange)
            }
    }
}
```

- The first button on an edge is the one a full swipe triggers, so put the
  most common action first and only allow full swipe when it is safe.
- A `.destructive` role colors the button red and animates the row out; the
  data change must actually remove the item.
- Use `.tint(_:)` for non-destructive buttons; keep each edge to about three
  actions.
- Swipe actions do nothing in `ScrollView` + `LazyVStack`; choose `List` when
  rows need them. Offer the same actions in a context menu or edit mode so
  they are discoverable.

More list recipes: [list reference](references/list.md).

## ScrollView

Use `ScrollView` with lazy stacks for custom layouts, mixed content and
horizontal strips.

```swift
ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack {
        ForEach(genres) { genre in GenreChip(genre: genre) }
    }
    .padding(.horizontal)
}
```

`ScrollPosition` (iOS 18) tracks position in both directions and scrolls on
request.
A chat screen pinned to the newest message:

```swift
struct ThreadView: View {
    let replies: [Reply]
    @State private var position = ScrollPosition(edge: .bottom)

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(replies) { ReplyBubble(reply: $0) }
            }
            .scrollTargetLayout()
        }
        .scrollPosition($position)
        .onChange(of: replies.last?.id) {
            withAnimation { position.scrollTo(edge: .bottom) }
        }
        .safeAreaInset(edge: .bottom) { ComposerBar() }
    }
}
```

`safeAreaInset(edge:)` keeps an input bar above the keyboard without changing
the scroll layout. iOS 26 adds:

- `.scrollEdgeEffectStyle(.soft, for: .top)` for a fading edge.
- `.backgroundExtensionEffect()` to mirror and blur content under safe-area
  edges. Use it sparingly, on one view per screen.
- `.safeAreaBar(edge:)` for bars that take part in the scroll edge effect.

Details: [scroll view reference](references/scrollview.md).

## Forms and controls

`Form` lays out structured input with the platform's spacing and accessibility.
Group related controls in `Section`s.

```swift
struct ReaderSettings: View {
    @AppStorage("reader.justify") private var justify = false
    @State private var theme: ReaderTheme = .paper
    @State private var fontScale = 1.0

    var body: some View {
        Form {
            Section("Text") {
                Toggle("Justify Text", isOn: $justify)
                Picker("Theme", selection: $theme) {
                    ForEach(ReaderTheme.allCases, id: \.self) { option in
                        Text(option.title).tag(option)
                    }
                }
                HStack {
                    Slider(value: $fontScale, in: 0.5...1.5, step: 0.1)
                    Text(fontScale, format: .number.precision(.fractionLength(1)))
                        .monospacedDigit()
                }
            }
        }
        .formStyle(.grouped)
        .scrollContentBackground(.hidden)
    }
}
```

- Use `@FocusState` to move the keyboard between fields in long forms.
- Add a `NavigationStack` around the form only for a sheet or a standalone
  presentation; pushed inside an existing stack it already has one.

| Control | For | Notes |
|---------|-----|-------|
| `Toggle` | On/off values | A `Label` gives icon plus text when it helps |
| `Picker` | One of several choices | `.segmented` only for 2 to 4 options; menu or inline beyond that |
| `Slider` | A numeric range | Always show the current value next to it, never hide the label |
| `DatePicker` | Dates and times | |
| `TextField` | Text | Set `.keyboardType` and `.textInputAutocapitalization` |

Bind controls straight to `@State`, `@Binding` or `@AppStorage`, and use
`.disabled(_:)` for settings that are locked or inherited. Validation and
modal forms: [form reference](references/form.md).

## Search

```swift
@MainActor
struct RecipeSearch: View {
    @State private var query = ""
    @State private var scope: RecipeScope = .all
    @State private var matches: [Recipe] = []
    @State private var isSearching = false

    var body: some View {
        List(matches) { RecipeRow(recipe: $0) }
            .overlay {
                if isSearching { ProgressView() }
                else if query.isEmpty { ContentUnavailableView("Find a Recipe", systemImage: "fork.knife") }
            }
            .searchable(
                text: $query,
                placement: .navigationBarDrawer(displayMode: .always),
                prompt: "Dishes or ingredients"
            )
            .searchScopes($scope) {
                ForEach(RecipeScope.allCases) { Text($0.title).tag($0) }
            }
            .task(id: query) { await search() }
    }

    private func search() async {
        guard !query.isEmpty else {
            matches = []
            return
        }
        isSearching = true
        defer { isSearching = false }
        try? await Task.sleep(for: .seconds(0.25))
        guard !Task.isCancelled else { return }
        matches = await RecipeIndex.shared.lookup(query, in: scope)
    }
}
```

`.task(id:)` cancels and restarts on every keystroke, so the short sleep acts
as a debounce. Skip empty queries, show a placeholder when there is nothing to
search, and let the screen own its search state rather than a shared model.

## Overlays and presentation

`.overlay(alignment:)` shows transient UI such as toasts and banners without
moving anything else.

```swift
struct SaveConfirmation: ViewModifier {
    @Binding var toast: String?

    func body(content base: Content) -> some View {
        base.overlay(alignment: .top) {
            if let toast {
                Text(toast)
                    .padding()
                    .background(.thinMaterial, in: Capsule())
                    .transition(.opacity.combined(with: .move(edge: .top)))
                    .task {
                        try? await Task.sleep(for: .milliseconds(2000))
                        withAnimation { self.toast = nil }
                    }
            }
        }
    }
}
```

- Prefer an overlay to inserting a banner into a stack; animate it in and out
  and dismiss it after a short time.
- Pin it to one clear edge (`.top` or `.bottom`).
- Do not block all interaction unless the moment requires it.
- Show one toast at a time: replace or queue, never pile them up.
- `.fullScreenCover(item:)` is for immersive screens such as media viewers or
  onboarding.

## Common mistakes

- Plain stacks for big collections: everything renders up front.
- `GeometryReader` inside lazy containers: laziness is lost.
- Array indices as `ForEach` ids: wrong diffs and glitchy updates.
- Nested scroll views on the same axis: competing gestures.
- Heavy custom row layouts in `List`: use `ScrollView` with `LazyVStack`.
- Missing `.contentShape(Rectangle())`: only the text responds to taps.
- Fixed frames on sheets: use `.presentationSizing` (iOS 18) instead.
- Searching with an empty string: guard it.
- `List` and `ScrollView` mixed in one hierarchy: gesture conflicts.
- Segmented pickers with many options: use menu or inline.
- `spacing:` hard-coded everywhere: leave it out so the platform picks
  adaptive spacing, and set it only for deliberately tight (0 to 4 pt) or wide
  gaps.
- Swipe actions added to rows outside a `List`: they never appear.

## Review checklist

- [ ] Large or dynamic collections use lazy stacks or `List`
- [ ] Every `ForEach` item has a stable `Identifiable` id
- [ ] No `GeometryReader` in lazy containers
- [ ] List style matches the content: `.plain` feed, `.insetGrouped` settings
- [ ] Structured input uses `Form`, not hand-built stacks
- [ ] `.searchable` work is debounced with `.task(id:)`
- [ ] `.refreshable` is present where the data can be reloaded
- [ ] Swipe actions sit on `List` rows, with the safe action first on full swipe
- [ ] Overlays animate in and dismiss themselves
- [ ] Tappable rows have `.contentShape(Rectangle())`
- [ ] `@FocusState` drives keyboard focus in forms
- [ ] `spacing:` appears only where a specific value is needed

## References

- [Grids](references/grids.md): grid types, columns, cell sizing, selection, performance
- [Lists](references/list.md): feeds with scroll-to-top, settings lists, edge effects
- [Scroll views](references/scrollview.md): lazy content, `ScrollPosition`, iOS 26 edge effects, `safeAreaBar`
- [Forms](references/form.md): settings forms, modal forms with validation
