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.

Create a free account to save this page, take notes and track your progress. Sign in

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

  • List is a scrolling, lazily rendered table with platform styling; ForEach creates one view per element.
  • Every element needs a stable identity: conform to Identifiable (an id property) or pass id: \.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; .swipeActions adds 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 body on 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:

Sections, search and stable identity
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; use Identifiable.
  • Non-unique IDs. Two rows with the same id confuse diffing and selection.
  • Sorting and grouping in body. It runs on every render; compute once in the model.
  • No empty state. Show ContentUnavailableView when a search or list has no results.

Key points

  • List and ForEach render 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.

Was this page helpful?