5.6 — Lists, forms & grids

Opening scenario

A new SwiftUI engineer ships a feed screen. It’s a ScrollView { VStack { ForEach(items) { ... } } }. Works fine — until production data hits 5,000 items. The screen takes 8 seconds to appear, scrolls choppy, and memory spikes to 600MB. The fix is one keyword: LazyVStack. Or better: List.

SwiftUI’s collection containers each pick a tradeoff. Pick wrong and you ship perf bugs. Pick right and the framework handles diffing, recycling, and accessibility for you.

ContainerLazy?Use for
ListYesStandard scrolling lists (uses platform list view)
FormYesGrouped settings/input forms
LazyVStack / LazyHStackYesCustom-styled lists inside ScrollView
VStack / HStackNoSmall fixed sets of views (< ~50)
LazyVGrid / LazyHGridYesGrid layouts (Instagram-style photo grid)
GridNoAligned cells, no scrolling (calculator UI)
TableYesMulti-column tables (macOS/iPadOS only)

Concept → Why → How → Code

List — the workhorse

List(items) { item in
    HStack {
        AsyncImage(url: item.imageURL)
            .frame(width: 50, height: 50)
        VStack(alignment: .leading) {
            Text(item.title).font(.headline)
            Text(item.subtitle).font(.subheadline).foregroundStyle(.secondary)
        }
    }
}

Under the hood on iOS, List wraps UICollectionView (was UITableView pre-iOS 16). Cells are recycled. The default styling is platform-appropriate.

List styles

List(items) { ... }
    .listStyle(.plain)        // no insets, edge-to-edge
    .listStyle(.insetGrouped) // iOS Settings look
    .listStyle(.grouped)      // legacy grouped
    .listStyle(.sidebar)      // macOS/iPad sidebar with disclosure groups

Each style has subtle differences in spacing, separators, background. .sidebar enables collapsible disclosure groups and matches platform conventions.

Sections

List {
    Section("Today") {
        ForEach(todayItems) { ItemRow(item: $0) }
    }
    Section("Yesterday") {
        ForEach(yesterdayItems) { ItemRow(item: $0) }
    }
    Section {
        ForEach(olderItems) { ItemRow(item: $0) }
    } header: {
        Text("Older")
    } footer: {
        Text("Older than 7 days").font(.caption)
    }
}

Sections enable headers, footers, and grouping. With .insetGrouped style, sections render as rounded card groups.

Swipe actions

List(items) { item in
    Text(item.title)
        .swipeActions(edge: .trailing) {
            Button("Delete", role: .destructive) {
                delete(item)
            }
            Button("Archive") {
                archive(item)
            }
            .tint(.orange)
        }
        .swipeActions(edge: .leading) {
            Button("Flag") { flag(item) }.tint(.yellow)
        }
}
  • edge: .trailing (right swipe) — destructive actions go here per HIG
  • edge: .leading (left swipe) — neutral/positive actions
  • First action shown is invoked on full swipe
  • role: .destructive colors red and confirms full-swipe destruction

onDelete / onMove / EditMode

List {
    ForEach(items) { ItemRow(item: $0) }
        .onDelete { offsets in items.remove(atOffsets: offsets) }
        .onMove { source, dest in items.move(fromOffsets: source, toOffset: dest) }
}
.toolbar { EditButton() }

Provides classic iOS edit-mode reorder and delete. Less common now than .swipeActions for delete, but .onMove + EditButton remains the standard for reorderable lists.

Selection

@State private var selection: Set<Item.ID> = []

List(items, selection: $selection) { item in
    Text(item.title)
}
.toolbar { EditButton() }
  • Single selection: @State var selection: Item.ID?
  • Multi-selection: @State var selection: Set<Item.ID> + edit mode
  • On macOS, selection works without edit mode (click to select)

Pull-to-refresh

List(items) { ... }
    .refreshable {
        await loadLatest()        // async closure
    }

refreshable provides system pull-to-refresh. The async closure suspends until refresh completes; the spinner displays during that time.

Searchable

List(filteredItems) { ... }
    .searchable(text: $query, prompt: "Search items")
    .searchScopes($scope) {
        Text("All").tag(Scope.all)
        Text("Active").tag(Scope.active)
    }

var filteredItems: [Item] {
    query.isEmpty ? items : items.filter { $0.title.localizedCaseInsensitiveContains(query) }
}

System-styled search field, integrates with navigation bar. .searchScopes adds segmented filter chips below.

Form — grouped input UI

struct SettingsView: View {
    @AppStorage("notifications") private var notifications = true
    @AppStorage("frequency") private var frequency = "daily"
    @State private var email = ""

    var body: some View {
        Form {
            Section("Account") {
                TextField("Email", text: $email)
                SecureField("Password", text: $password)
            }
            Section("Notifications") {
                Toggle("Enabled", isOn: $notifications)
                Picker("Frequency", selection: $frequency) {
                    Text("Daily").tag("daily")
                    Text("Weekly").tag("weekly")
                }
            }
            Section {
                Button("Sign out", role: .destructive) { signOut() }
            }
        }
    }
}

Form is List with adaptive styling: iOS Settings-style on iOS, indented labels on macOS. Use for any settings/input UI. Don’t reach for Form for content lists — use List.

LazyVStack / LazyHStack — custom lists in ScrollView

When List styling doesn’t fit:

ScrollView {
    LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
        Section {
            ForEach(items) { item in
                CustomCard(item: item)
            }
        } header: {
            HStack { Text("Today").font(.title2); Spacer() }
                .background(.regularMaterial)
        }
    }
}
  • Lazy: views off-screen are not instantiated
  • pinnedViews: [.sectionHeaders] for sticky headers
  • More layout flexibility than List (custom backgrounds, full-width cells, etc.)
  • Lose: built-in swipe actions, selection, separators, accessibility traits

Use LazyVStack when:

  • You need a custom card-style design that doesn’t fit list cell conventions
  • You need pinned section headers
  • You need a non-list-shaped scroll (e.g., heterogeneous content above a feed)

Use List when you can — you get more for free.

Grids

let columns = [
    GridItem(.adaptive(minimum: 100), spacing: 8)
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 8) {
        ForEach(photos) { photo in
            AsyncImage(url: photo.thumbnailURL) { image in
                image.resizable().aspectRatio(1, contentMode: .fill)
            } placeholder: {
                Color.gray.opacity(0.2)
            }
            .frame(height: 100)
            .clipped()
        }
    }
    .padding(8)
}

GridItem types:

  • .fixed(width) — fixed-width column
  • .flexible(minimum:, maximum:) — fills available space, bounded
  • .adaptive(minimum:, maximum:) — fits as many columns as possible at min width

Adaptive grids are the Instagram pattern — 3 columns on iPhone, 5 on iPad, more on Mac.

Grid (non-lazy, aligned)

Grid(horizontalSpacing: 16, verticalSpacing: 8) {
    GridRow {
        Text("Name").gridColumnAlignment(.trailing)
        TextField("Name", text: $name)
    }
    GridRow {
        Text("Email").gridColumnAlignment(.trailing)
        TextField("Email", text: $email)
    }
    GridRow {
        Color.clear
            .gridCellUnsizedAxes([.horizontal, .vertical])
        Button("Save") { save() }
    }
}

Grid (iOS 16+) is a non-scrolling, non-lazy layout container with column alignment — like CSS Grid. Use for forms with aligned labels, calculator-style layouts, dashboards.

Table (macOS, iPadOS)

Multi-column tables with sortable headers:

struct OrdersTable: View {
    @State private var orders: [Order] = []
    @State private var sortOrder: [KeyPathComparator<Order>] = []

    var body: some View {
        Table(orders, sortOrder: $sortOrder) {
            TableColumn("ID", value: \.id.uuidString)
            TableColumn("Customer", value: \.customer)
            TableColumn("Amount", value: \.amount) { order in
                Text(order.amount, format: .currency(code: "USD"))
            }
        }
        .onChange(of: sortOrder) { _, new in
            orders.sort(using: new)
        }
    }
}

iPadOS 16+ supports Table. iOS (iPhone) collapses Table to a list. Most use Table for productivity apps; consumer apps stick to List.

Performance gotchas

  1. ForEach without stable IDs triggers full re-renders. Use Identifiable or id: \.someStable.
  2. AsyncImage without .id(url) can flicker on reuse. Apply .id() to force fresh state when URL changes.
  3. Computing derived data in body — heavy filters/sorts in var body run every render. Hoist to @Observable model.
  4. Reading large model objects in cell — even with @Observable per-property tracking, if a cell reads model.everything you re-render on any change. Pass only the data the cell needs.
  5. Heterogeneous cells in LazyVStack — varying row heights cause more work. Acceptable; just don’t expect List-level perf for tens of thousands of mixed rows.

Diffing — identity matters

struct Item: Identifiable {
    let id: UUID
    var title: String
}

List(items) { item in
    Text(item.title)
}

When items changes, SwiftUI diffs old vs new by id and animates inserts/removes. If you use id: \.title and two items share a title, you get visual glitches. Always use a truly unique, stable identity.

Async data loading pattern

struct FeedView: View {
    @State private var model = FeedModel()

    var body: some View {
        List(model.items) { item in ItemRow(item: item) }
            .overlay {
                if model.isLoading && model.items.isEmpty {
                    ProgressView()
                }
            }
            .refreshable { await model.refresh() }
            .task { await model.loadInitial() }
    }
}
  • .task { ... } runs when view appears, cancels on disappear (good!)
  • .refreshable for pull-to-refresh
  • Overlay for empty-state spinner

In the wild

  • Apple Mail uses List + .swipeActions + .searchable — exactly the pattern in this chapter.
  • Instagram is LazyVGrid with .adaptive for the profile grid; the feed itself is LazyVStack for custom card design.
  • Apple Settings is the canonical Form example — sections, toggles, pickers, disclosure rows.
  • Apple’s Reminders app uses List with custom row content, including the inline-edit text fields.
  • Notion’s iPad app uses Table for database views with sortable columns.

Common misconceptions

  1. List and LazyVStack are interchangeable.” They’re not. List gives you swipe actions, selection, separators, edit mode, accessibility. LazyVStack gives you custom styling freedom. Pick based on what you need.
  2. VStack is fine for any list.” No — VStack instantiates every child upfront. With 5,000 items, it’s catastrophic. Use List or LazyVStack.
  3. Form is for any input.” Form adds platform-specific styling. Use it for settings-style input. For a one-off TextField in a custom flow, VStack is fine.
  4. “You can’t customize List appearance.” You can — .listRowBackground, .listRowSeparator(.hidden), .listRowInsets(), .scrollContentBackground(.hidden) (combined with .background(...) for a custom backdrop).
  5. AsyncImage is good enough for image grids.” It’s fine for thumbnails but lacks caching beyond URL session. For real photo grids, use a caching library (SDWebImage, Nuke, Kingfisher) wrapped in a UIViewRepresentable, or roll your own cache.

Seasoned engineer’s take

List first. Only reach for LazyVStack when you have a concrete reason. The amount of accessibility and platform behavior you give up by hand-rolling list UI is enormous and most teams underestimate it.

For forms: Form is criminally underused. Engineers reach for custom VStack layouts when Form would have produced a more native-looking, more accessible, more localizable result with less code.

For grids: LazyVGrid with adaptive columns is the default. If you need fixed columns and complex per-cell sizing, you might be reaching for a custom layout — consider Layout protocol (iOS 16+) rather than nested stacks.

Watch out for accidentally non-lazy lists. ScrollView { ForEach(...) { ... } } (no LazyVStack) silently becomes eager. Always wrap with LazyVStack or use List.

TIP: When debugging list perf, add let _ = Self._printChanges() to your row view’s body. You’ll see every re-render and why. Then optimize.

WARNING: List cell reuse means @State inside a cell can leak between rows if your IDs are unstable. Always use Identifiable with truly unique IDs.

Interview corner

Junior-level: “What’s the difference between List and ScrollView { VStack { ForEach { ... } } }?”

List is a lazy container backed by the platform’s native list view; only visible cells are instantiated, and you get cell recycling, swipe actions, selection, and edit mode for free. ScrollView + VStack instantiates all children upfront — slow for large datasets. The lazy version is ScrollView { LazyVStack { ForEach { ... } } } which only instantiates visible children.

Mid-level: “You have a 10,000-item feed with custom card styling, pull-to-refresh, and per-card swipe actions. What container do you use?”

List with .listStyle(.plain), .listRowSeparator(.hidden), .listRowBackground(Color.clear), custom card view in the row. This keeps native swipe actions, accessibility, and lazy loading. If the card styling absolutely cannot work as a list row (e.g., overlapping cards or pinned headers in the middle of scrolling), then LazyVStack in a ScrollView with manual swipe-gesture implementation, but you give up a lot. Start with List; switch only with evidence.

Senior-level: “Walk me through optimizing a list that’s scrolling at 40fps.”

  1. Profile with Instruments (Time Profiler, SwiftUI template, Hangs).
  2. Check whether the list is actually lazy. Confirm List or LazyVStack; rule out an accidental VStack.
  3. Use Self._printChanges() in row view; identify rows re-rendering on every scroll. Common cause: row reads parent state that changes per-scroll (e.g., scroll offset).
  4. Check identity stability — non-stable IDs cause full diff churn.
  5. Check whether row computes expensive properties in body (formatting dates, parsing strings). Hoist to data layer.
  6. Check AsyncImage usage — if rows show images that load synchronously or compute thumbnails inline, replace with a caching solution.
  7. Check @Observable model granularity — if rows read a giant model and any property change re-renders, split into per-row models or pass only needed data.
  8. If using nested LazyVStacks — flatten or use LazyVGrid.
  9. Consider drawingGroup() for complex composited rows (renders to offscreen layer).
  10. Last resort: drop to a UICollectionView wrapped in UIViewRepresentable for absolute control.

Red flag in candidates: Reaching for LazyVStack instead of List without naming a specific reason. Or building custom swipe gestures when .swipeActions exists.

Lab preview

Lab 5.1 uses List with .swipeActions (complete and delete) and Form for the add/edit screen. Lab 5.3 uses List in a sidebar and a custom detail view.


Next: Animations & transitions