Skip to content

Book 08 Lists Grids And ForEach

Michael Fluharty edited this page May 1, 2026 · 10 revisions

Book 08: Lists Grids And ForEach

Part III — The User Interface · Claude's Xcode 26 Swift Bible

Book-07-Toolbars-And-Tab-Views · Chapters and Appendices · Book-09-Text-And-TextField


Live Reference: Inkwell + Claudes LockBox + QuickNote

Inkwell (the reader app you may be holding right now) uses List for its sidebar navigation — every Part, Book, Chapter, and Page is a list row driven by @Query against the bundle's content tree. Claudes LockBox uses List for the items-in-folder column (the middle of the three-column layout) plus a LazyVGrid for the photo thumbnail strip in vault item detail. QuickNote uses List for the notes index, sorted by date, with @Query on the SwiftData model. All three apps put List and ForEach to production work. Sources: Inkwell, LockBox, QuickNote. See Build-Along 00 (Inkwell), Build-Along 03 (LockBox), Build-Along 02 (QuickNote).


X26 Updates to Lists, Tables, and Forms

X26 ships taller rows and more padding for List, Table, and Form, plus a larger corner radius on sections so list groupings echo the rounded shape of nearby controls[[l1]]. You get all of it for free in most apps. As long as you haven't hand-set row heights or section insets, a rebuild against the X26 SDK is the whole adoption story.

Section Headers Use Title-Style Capitalization

This one needs your attention even on standard components. Section headers in lists, tables, and forms now render in title-style capitalization — "Profile", not "PROFILE". Pre-X26 the system flipped your header to all-caps no matter what you handed it, so a lot of source code carries the header in capitals out of habit. Read your header strings and switch them to title case so they sit cleanly next to the system's own.

Adopt Forms with the Grouped Style

For forms, Form with the grouped style picks up the X26 layout updates with no extra code:

Form {
    Section("Profile") {
        TextField("Name", text: $name)
        TextField("Email", text: $email)
    }
    Section("Preferences") {
        Toggle("Email Notifications", isOn: $emailOn)
        Picker("Theme", selection: $theme) {
            Text("Light").tag(Theme.light)
            Text("Dark").tag(Theme.dark)
        }
    }
}
.formStyle(.grouped)

Grouped is the same form style iOS has used for years. X26 only retunes the dimensions. The Mac side picks up the matching metric updates, so a form built once now looks the same on iPhone, iPad, and Mac without per-platform tweaks.

[l1] Apple Developer Documentation, *Adopting Liquid Glass*. [developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass](https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass) — verified 2026-04-29.


List

The primary container for data display in SwiftUI. Provides scrolling, cell recycling, selection, swipe actions, and platform-native styling for free.

Basic List

struct ItemListView: View {
    let items: [Item]

    var body: some View {
        List(items) { item in
            Text(item.name)
        }
    }
}

This requires Item to conform to Identifiable. If it does not, provide a key path:

List(names, id: \.self) { name in
    Text(name)
}

Static Content

List {
    Text("First")
    Text("Second")
    Text("Third")
}

Mixed Static and Dynamic

List {
    Section("Favorites") {
        ForEach(favorites) { item in
            Text(item.name)
        }
    }

    Section("All Items") {
        ForEach(allItems) { item in
            Text(item.name)
        }
    }
}

ForEach

ForEach is not a loop — it is a view that generates views from a collection.

With Identifiable Data

ForEach(recipes) { recipe in
    RecipeRow(recipe: recipe)
}

With id Key Path

ForEach(names, id: \.self) { name in
    Text(name)
}

With Index

ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    Text("\(index + 1). \(item.name)")
}

Range-Based

ForEach(0..<5) { index in
    Text("Row \(index)")
}

Watch Out

  • ForEach with a range (0..<count) requires the range to be constant. If count changes, use ForEach(0..<count, id: \.self) — but better yet, use a real data collection.
  • The id parameter is critical for performance. SwiftUI uses it to track which rows changed. If two items share the same ID, you get undefined behavior — wrong rows update, animations break.

Identifiable Protocol

struct Recipe: Identifiable {
    let id = UUID()
    var name: String
    var cuisine: String
}

Identifiable requires a single property: id. It can be any Hashable type — UUID, Int, String.

Watch Out

  • let id = UUID() generates a new ID every time the struct is created. If you recreate structs from network data, use a stable identifier from the server instead.

Sections

List {
    Section("Breakfast") {
        ForEach(breakfastItems) { item in
            Text(item.name)
        }
    }

    Section {
        ForEach(lunchItems) { item in
            Text(item.name)
        }
    } header: {
        Text("Lunch")
    } footer: {
        Text("All items under 500 calories")
    }
}

Collapsible Sections (iOS 17+)

Section("Advanced", isExpanded: $isAdvancedExpanded) {
    Toggle("Debug Mode", isOn: $debugMode)
    Toggle("Verbose Logging", isOn: $verboseLogging)
}

Swipe Actions

List {
    ForEach(messages) { message in
        MessageRow(message: message)
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    delete(message)
                } label: {
                    Label("Delete", systemImage: "trash")
                }

                Button {
                    archive(message)
                } label: {
                    Label("Archive", systemImage: "archivebox")
                }
                .tint(.blue)
            }
            .swipeActions(edge: .leading) {
                Button {
                    toggleRead(message)
                } label: {
                    Label("Read", systemImage: "envelope.open")
                }
                .tint(.green)
            }
    }
}

Watch Out

  • The first trailing swipe action with role: .destructive gets a full-swipe gesture for quick deletion. Be careful — users may trigger it accidentally.
  • Swipe actions only work inside a List. They do nothing in a ScrollView or LazyVStack.

onDelete and onMove

The classic List editing modifiers. These work on ForEach, not on List directly.

List {
    ForEach(items) { item in
        Text(item.name)
    }
    .onDelete(perform: deleteItems)
    .onMove(perform: moveItems)
}
.toolbar {
    EditButton()
}

func deleteItems(at offsets: IndexSet) {
    items.remove(atOffsets: offsets)
}

func moveItems(from source: IndexSet, to destination: Int) {
    items.move(fromOffsets: source, toOffset: destination)
}

Watch Out

  • onDelete adds the standard swipe-to-delete gesture. If you also use .swipeActions, onDelete is ignored — .swipeActions takes priority.
  • onMove only works when the List is in edit mode. Add an EditButton() to the toolbar.

Refreshable

Pull-to-refresh. The closure is async, so you can await network calls directly.

List(items) { item in
    Text(item.name)
}
.refreshable {
    await loadItems()
}

Watch Out

  • .refreshable only works on scrollable views (List, ScrollView). It does nothing on a plain VStack.
  • The refresh indicator stays visible until the async closure completes. If your network call is fast, the spinner may flash briefly. That is expected.

Searchable

Adds a search bar to a NavigationStack or NavigationSplitView.

struct RecipeListView: View {
    @State private var searchText = ""
    let recipes: [Recipe]

    var filteredRecipes: [Recipe] {
        if searchText.isEmpty {
            return recipes
        }
        return recipes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filteredRecipes) { recipe in
                Text(recipe.name)
            }
            .navigationTitle("Recipes")
            .searchable(text: $searchText, prompt: "Find a recipe")
        }
    }
}

Search Suggestions

.searchable(text: $searchText) {
    ForEach(suggestions, id: \.self) { suggestion in
        Text(suggestion).searchCompletion(suggestion)
    }
}

Search Scopes

.searchable(text: $searchText)
.searchScopes($searchScope) {
    Text("All").tag(SearchScope.all)
    Text("Name").tag(SearchScope.name)
    Text("Ingredient").tag(SearchScope.ingredient)
}

Watch Out

  • .searchable must be inside a NavigationStack or NavigationSplitView. Outside of navigation, the search bar does not appear.
  • The search bar placement varies by platform. On iOS it is under the navigation title; on macOS it is in the toolbar.

List Styles

List { ... }
    .listStyle(.plain)          // no grouped background
    .listStyle(.inset)          // padded from edges
    .listStyle(.grouped)        // iOS grouped sections
    .listStyle(.insetGrouped)   // rounded grouped sections (iOS default in many contexts)
    .listStyle(.sidebar)        // macOS sidebar style, disclosure triangles

Row Customization

List {
    ForEach(items) { item in
        Text(item.name)
            .listRowBackground(Color.yellow.opacity(0.2))
            .listRowSeparator(.hidden)
            .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
    }
}

Selection

Single Selection

@State private var selected: Item.ID?

List(items, selection: $selected) { item in
    Text(item.name)
}

Multiple Selection

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

List(items, selection: $selected) { item in
    Text(item.name)
}
.toolbar { EditButton() }

Multiple selection requires edit mode on iOS. On macOS, it works directly with Command-click and Shift-click.


LazyVGrid and LazyHGrid

For grid layouts. These live inside a ScrollView, not a List.

LazyVGrid (Vertical Scrolling Grid)

let columns = [
    GridItem(.adaptive(minimum: 150))
]

ScrollView {
    LazyVGrid(columns: columns, spacing: 16) {
        ForEach(photos) { photo in
            PhotoCard(photo: photo)
        }
    }
    .padding()
}

LazyHGrid (Horizontal Scrolling Grid)

let rows = [
    GridItem(.fixed(100)),
    GridItem(.fixed(100))
]

ScrollView(.horizontal) {
    LazyHGrid(rows: rows, spacing: 12) {
        ForEach(items) { item in
            ItemCard(item: item)
        }
    }
    .padding()
}

GridItem

GridItem defines how columns (in LazyVGrid) or rows (in LazyHGrid) are sized.

.fixed

Exact size. The column is always this wide.

GridItem(.fixed(120))

.flexible

Grows to fill available space, within optional min/max bounds.

GridItem(.flexible())                           // fills available space
GridItem(.flexible(minimum: 100, maximum: 200)) // bounded

.adaptive

Fits as many columns as possible within the available width. This is the most useful for responsive grids.

GridItem(.adaptive(minimum: 150))
// On a 390pt iPhone: 2 columns
// On a 1024pt iPad: 6 columns
// Adjusts automatically when the device rotates

Combining Grid Items

let columns = [
    GridItem(.fixed(60)),           // narrow first column
    GridItem(.flexible()),          // fills remaining space
    GridItem(.flexible())           // shares remaining space
]

Watch Out

  • .adaptive creates as many columns as fit. You do not control the exact count — it is calculated from available width and the minimum size. If you want exactly 3 columns, use three .flexible() items.
  • Grid items accept spacing and alignment parameters: GridItem(.flexible(), spacing: 8, alignment: .top).

ScrollView

Basic Scroll View

ScrollView {
    VStack(spacing: 12) {
        ForEach(items) { item in
            ItemCard(item: item)
        }
    }
    .padding()
}

Horizontal Scroll

ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 16) {
        ForEach(featured) { item in
            FeaturedCard(item: item)
                .frame(width: 280)
        }
    }
    .padding(.horizontal)
}

Scroll Position (iOS 17+)

@State private var scrollPosition: Item.ID?

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
    .scrollTargetLayout()
}
.scrollPosition(id: $scrollPosition)

Programmatically scroll by setting scrollPosition to an item's ID.

Watch Out

  • ScrollView does not recycle views. Every view in a ScrollView is created immediately unless you use LazyVStack or LazyHStack inside it.
  • .scrollIndicators(.hidden) hides the scrollbar. Use it for horizontal carousels. Avoid hiding it for vertical content — users need the scrollbar to orient themselves.

LazyVStack and LazyHStack

These create views on demand as they scroll into view. Essential for large data sets.

ScrollView {
    LazyVStack(spacing: 12) {
        ForEach(items) { item in
            ItemRow(item: item)
        }
    }
}

Pinned Headers

ScrollView {
    LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
        ForEach(sections) { section in
            Section {
                ForEach(section.items) { item in
                    ItemRow(item: item)
                }
            } header: {
                Text(section.title)
                    .font(.headline)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()
                    .background(.bar)
            }
        }
    }
}

Performance: Lazy vs Eager

| Container | View Creation | Use When | |-----------|--------------|----------| | VStack | All at once | Small collections (under 50 items) | | LazyVStack | On demand | Large or unbounded collections | | List | On demand (with recycling) | Data lists needing swipe actions, selection | | LazyVGrid | On demand | Grid layouts in ScrollView |

Watch Out

  • LazyVStack creates views as they scroll in, but does not destroy them when they scroll out. For very long lists (thousands of items), memory grows over time. List handles this better because it recycles cells.
  • Do not put a LazyVStack inside a List. The List already handles lazy loading. Nesting them causes layout conflicts.
  • LazyVStack does not support swipe actions, onDelete, or onMove. Those are List-only features.
  • LazyVStack alignment defaults to .center. For left-aligned content, use LazyVStack(alignment: .leading).

Practical Tips

  1. Use List by default for data that needs selection, swipe actions, or editing. Switch to ScrollView + LazyVStack when you need custom layouts or grid arrangements.

  2. Always provide stable IDs. Using array indices as IDs causes incorrect animations and state bugs when items are inserted or removed.

  3. Use .adaptive GridItem for responsive layouts that work across iPhone, iPad, and Mac without conditional logic.

  4. Combine .searchable and .refreshable on the same List for a standard data browsing experience with minimal code.

  5. Test with large data sets. A list that works fine with 10 items may stutter at 10,000. Profile with Instruments if scrolling feels slow.

  6. Prefer LazyVStack over VStack inside a ScrollView any time the content count is dynamic or could grow. The memory difference is significant.

  7. Section headers and footers in List get automatic styling. In LazyVStack with pinnedViews, you style them yourself — but you get sticky headers for free.


Book-07-Toolbars-And-Tab-Views · Chapters and Appendices · Book-09-Text-And-TextField

Feedback: Found something off? Open an issue · Discuss it · Email Michael

Clone this wiki locally