iOS with SwiftUI · SwiftUI Foundations: Views, Layout, Lists and State
Lists, ForEach, Sections and Search
Show collections with List and ForEach, give rows stable identity with Identifiable, group them in sections, add swipe actions, pull to refresh and search.
What you'll build
An order history screen: orders grouped by month in sections, a search field that filters as you type, swipe to archive, and pull to refresh.
The idea
Listis a scrolling, lazily rendered table with platform styling;ForEachcreates one view per element.- Every element needs a stable identity: conform to
Identifiable(anidproperty) or passid: \.someKey. Identity tells SwiftUI which row is which when data changes, so animations and row state stay with the right item. Using array indices as identity breaks this. Section("Title") { }groups rows;.swipeActionsadds actions on a row;.refreshable { await ... }adds pull to refresh;.searchable(text:)adds a search field.- Prepare grouped and filtered data in a model or computed property, not by sorting inside
bodyon every render.
The code
struct Order: Identifiable, Hashable {
let id: Int
let month: String
let summary: String
let total: Double
}
struct OrderHistoryView: View {
@State private var model = OrderHistoryModel()
@State private var query = ""
var body: some View {
List {
ForEach(model.sections(matching: query), id: \.month) { section in
Section(section.month) {
ForEach(section.orders) { order in
LabeledContent("Order #\(order.id)", value: order.total, format: .currency(code: "EUR"))
.swipeActions {
Button("Archive", systemImage: "archivebox") { model.archive(order) }
.tint(.orange)
}
}
}
}
}
.searchable(text: $query, prompt: "Search orders")
.refreshable { await model.refresh() }
.overlay {
if model.isEmpty(matching: query) {
ContentUnavailableView.search(text: query)
}
}
}
}
Try the logic
Filtering and grouping into sections, and why identity by index goes wrong when an order is inserted at the top:
import Foundation
struct Order: Identifiable {
let id: Int
let month: String
let summary: String
}
struct MonthSection {
let month: String
let orders: [Order]
}
func sections(_ orders: [Order], matching query: String) -> [MonthSection] {
let filtered = query.isEmpty ? orders : orders.filter { $0.summary.localizedCaseInsensitiveContains(query) }
return Dictionary(grouping: filtered, by: { $0.month })
.map { MonthSection(month: $0.key, orders: $0.value.sorted { $0.id > $1.id }) }
.sorted { $0.month > $1.month }
}
let orders = [Order(id: 101, month: "2026-09", summary: "2 mugs"),
Order(id: 102, month: "2026-09", summary: "Teapot"),
Order(id: 103, month: "2026-10", summary: "Mug and tea")]
for s in sections(orders, matching: "") { print(s.month, s.orders.map(\.id)) }
for s in sections(orders, matching: "MUG") { print("search 'MUG':", s.month, s.orders.map(\.id)) }
let before = [103, 102, 101]
let after = [104, 103, 102, 101]
print("selected by index 0: before \(before[0]), after \(after[0]) (wrong row)")
print("selected by id 103: still \(after.first { $0 == 103 }!)")
Output
2026-10 [103] 2026-09 [102, 101] search 'MUG': 2026-10 [103] search 'MUG': 2026-09 [101] selected by index 0: before 103, after 104 (wrong row) selected by id 103: still 103
Run this on your computer — see “Get Started” for setup.
Common mistakes
ForEach(0..<items.count)with changing data. Identity by index causes wrong animations and crashes when items are removed; useIdentifiable.- Non-unique IDs. Two rows with the same
idconfuse diffing and selection. - Sorting and grouping in
body. It runs on every render; compute once in the model. - No empty state. Show
ContentUnavailableViewwhen a search or list has no results.
Key points
ListandForEachrender collections lazily; elements need stable, unique identity.- Sections, swipe actions, pull to refresh and search are built-in modifiers.
- Prepare filtered and grouped data outside
body. - Always design the empty and loading states.
Further reading: Lists, Displaying data in lists.
Practice questions
Why must items in a SwiftUI ForEach have stable identity?
Answer
SwiftUI uses identity to match old and new rows when data changes. With stable IDs it can animate insertions and removals and keep each row's state; with indices or unstable IDs, state and animations attach to the wrong rows, and removing items can even crash.
Where should filtering and sorting for a list happen?
Answer
In the model or view model (or a cached computed value), not repeatedly inside body. body can run many times per second during animations or typing, so expensive work there makes scrolling and typing slow.
Open in the app to mark lessons complete, take notes and track your progress.