-
Notifications
You must be signed in to change notification settings - Fork 0
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 →
Inkwell (the reader app you may be holding right now) uses
Listfor its sidebar navigation — every Part, Book, Chapter, and Page is a list row driven by@Queryagainst the bundle's content tree. Claudes LockBox usesListfor the items-in-folder column (the middle of the three-column layout) plus aLazyVGridfor the photo thumbnail strip in vault item detail. QuickNote usesListfor the notes index, sorted by date, with@Queryon the SwiftData model. All three apps putListandForEachto production work. Sources: Inkwell, LockBox, QuickNote. See Build-Along 00 (Inkwell), Build-Along 03 (LockBox), Build-Along 02 (QuickNote).
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.
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.
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.
The primary container for data display in SwiftUI. Provides scrolling, cell recycling, selection, swipe actions, and platform-native styling for free.
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)
}List {
Text("First")
Text("Second")
Text("Third")
}List {
Section("Favorites") {
ForEach(favorites) { item in
Text(item.name)
}
}
Section("All Items") {
ForEach(allItems) { item in
Text(item.name)
}
}
}ForEach is not a loop — it is a view that generates views from a collection.
ForEach(recipes) { recipe in
RecipeRow(recipe: recipe)
}ForEach(names, id: \.self) { name in
Text(name)
}ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
Text("\(index + 1). \(item.name)")
}ForEach(0..<5) { index in
Text("Row \(index)")
}-
ForEachwith a range (0..<count) requires the range to be constant. Ifcountchanges, useForEach(0..<count, id: \.self)— but better yet, use a real data collection. - The
idparameter 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.
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.
-
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.
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")
}
}Section("Advanced", isExpanded: $isAdvancedExpanded) {
Toggle("Debug Mode", isOn: $debugMode)
Toggle("Verbose Logging", isOn: $verboseLogging)
}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)
}
}
}- The first trailing swipe action with
role: .destructivegets 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 aScrollVieworLazyVStack.
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)
}-
onDeleteadds the standard swipe-to-delete gesture. If you also use.swipeActions,onDeleteis ignored —.swipeActionstakes priority. -
onMoveonly works when the List is in edit mode. Add anEditButton()to the toolbar.
Pull-to-refresh. The closure is async, so you can await network calls directly.
List(items) { item in
Text(item.name)
}
.refreshable {
await loadItems()
}-
.refreshableonly works on scrollable views (List,ScrollView). It does nothing on a plainVStack. - The refresh indicator stays visible until the
asyncclosure completes. If your network call is fast, the spinner may flash briefly. That is expected.
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")
}
}
}.searchable(text: $searchText) {
ForEach(suggestions, id: \.self) { suggestion in
Text(suggestion).searchCompletion(suggestion)
}
}.searchable(text: $searchText)
.searchScopes($searchScope) {
Text("All").tag(SearchScope.all)
Text("Name").tag(SearchScope.name)
Text("Ingredient").tag(SearchScope.ingredient)
}-
.searchablemust be inside aNavigationStackorNavigationSplitView. 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 { ... }
.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 trianglesList {
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))
}
}@State private var selected: Item.ID?
List(items, selection: $selected) { item in
Text(item.name)
}@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.
For grid layouts. These live inside a ScrollView, not a List.
let columns = [
GridItem(.adaptive(minimum: 150))
]
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(photos) { photo in
PhotoCard(photo: photo)
}
}
.padding()
}let rows = [
GridItem(.fixed(100)),
GridItem(.fixed(100))
]
ScrollView(.horizontal) {
LazyHGrid(rows: rows, spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding()
}GridItem defines how columns (in LazyVGrid) or rows (in LazyHGrid) are sized.
Exact size. The column is always this wide.
GridItem(.fixed(120))Grows to fill available space, within optional min/max bounds.
GridItem(.flexible()) // fills available space
GridItem(.flexible(minimum: 100, maximum: 200)) // boundedFits 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 rotateslet columns = [
GridItem(.fixed(60)), // narrow first column
GridItem(.flexible()), // fills remaining space
GridItem(.flexible()) // shares remaining space
]-
.adaptivecreates 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
spacingandalignmentparameters:GridItem(.flexible(), spacing: 8, alignment: .top).
ScrollView {
VStack(spacing: 12) {
ForEach(items) { item in
ItemCard(item: item)
}
}
.padding()
}ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(featured) { item in
FeaturedCard(item: item)
.frame(width: 280)
}
}
.padding(.horizontal)
}@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.
-
ScrollViewdoes not recycle views. Every view in aScrollViewis created immediately unless you useLazyVStackorLazyHStackinside it. -
.scrollIndicators(.hidden)hides the scrollbar. Use it for horizontal carousels. Avoid hiding it for vertical content — users need the scrollbar to orient themselves.
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)
}
}
}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)
}
}
}
}| 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 |
-
LazyVStackcreates 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.Listhandles this better because it recycles cells. - Do not put a
LazyVStackinside aList. The List already handles lazy loading. Nesting them causes layout conflicts. -
LazyVStackdoes not support swipe actions, onDelete, or onMove. Those areList-only features. -
LazyVStackalignment defaults to.center. For left-aligned content, useLazyVStack(alignment: .leading).
-
Use
Listby default for data that needs selection, swipe actions, or editing. Switch toScrollView+LazyVStackwhen you need custom layouts or grid arrangements. -
Always provide stable IDs. Using array indices as IDs causes incorrect animations and state bugs when items are inserted or removed.
-
Use
.adaptiveGridItem for responsive layouts that work across iPhone, iPad, and Mac without conditional logic. -
Combine
.searchableand.refreshableon the same List for a standard data browsing experience with minimal code. -
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.
-
Prefer
LazyVStackoverVStackinside aScrollViewany time the content count is dynamic or could grow. The memory difference is significant. -
Section headers and footers in List get automatic styling. In
LazyVStackwithpinnedViews, 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
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo