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.
| Container | Lazy? | Use for |
|---|---|---|
List | Yes | Standard scrolling lists (uses platform list view) |
Form | Yes | Grouped settings/input forms |
LazyVStack / LazyHStack | Yes | Custom-styled lists inside ScrollView |
VStack / HStack | No | Small fixed sets of views (< ~50) |
LazyVGrid / LazyHGrid | Yes | Grid layouts (Instagram-style photo grid) |
Grid | No | Aligned cells, no scrolling (calculator UI) |
Table | Yes | Multi-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 HIGedge: .leading(left swipe) — neutral/positive actions- First action shown is invoked on full swipe
role: .destructivecolors 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
ForEachwithout stable IDs triggers full re-renders. UseIdentifiableorid: \.someStable.AsyncImagewithout.id(url)can flicker on reuse. Apply.id()to force fresh state when URL changes.- Computing derived data in
body— heavy filters/sorts invar bodyrun every render. Hoist to@Observablemodel. - Reading large model objects in cell — even with
@Observableper-property tracking, if a cell readsmodel.everythingyou re-render on any change. Pass only the data the cell needs. - Heterogeneous cells in
LazyVStack— varying row heights cause more work. Acceptable; just don’t expectList-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!).refreshablefor 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
LazyVGridwith.adaptivefor the profile grid; the feed itself isLazyVStackfor custom card design. - Apple Settings is the canonical
Formexample — sections, toggles, pickers, disclosure rows. - Apple’s Reminders app uses
Listwith custom row content, including the inline-edit text fields. - Notion’s iPad app uses
Tablefor database views with sortable columns.
Common misconceptions
- “
ListandLazyVStackare interchangeable.” They’re not.Listgives you swipe actions, selection, separators, edit mode, accessibility.LazyVStackgives you custom styling freedom. Pick based on what you need. - “
VStackis fine for any list.” No —VStackinstantiates every child upfront. With 5,000 items, it’s catastrophic. UseListorLazyVStack. - “
Formis for any input.”Formadds platform-specific styling. Use it for settings-style input. For a one-offTextFieldin a custom flow,VStackis fine. - “You can’t customize
Listappearance.” You can —.listRowBackground,.listRowSeparator(.hidden),.listRowInsets(),.scrollContentBackground(.hidden)(combined with.background(...)for a custom backdrop). - “
AsyncImageis 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 aUIViewRepresentable, 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:
Listcell reuse means@Stateinside a cell can leak between rows if your IDs are unstable. Always useIdentifiablewith 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.”
- Profile with Instruments (Time Profiler, SwiftUI template, Hangs).
- Check whether the list is actually lazy. Confirm
ListorLazyVStack; rule out an accidentalVStack. - 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). - Check identity stability — non-stable IDs cause full diff churn.
- Check whether row computes expensive properties in
body(formatting dates, parsing strings). Hoist to data layer. - Check
AsyncImageusage — if rows show images that load synchronously or compute thumbnails inline, replace with a caching solution. - Check
@Observablemodel granularity — if rows read a giant model and any property change re-renders, split into per-row models or pass only needed data. - If using nested
LazyVStacks — flatten or useLazyVGrid. - Consider
drawingGroup()for complex composited rows (renders to offscreen layer). - Last resort: drop to a
UICollectionViewwrapped inUIViewRepresentablefor 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