iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Lists

SwiftUIs List is the modern table view: declarative, diffable, search-aware, swipe actions, and pull-to-refresh out of the box. Pair with .listStyle and ForEach for the look you want, and reach for LazyVStack inside a ScrollView only when List style does not fit.

List patterns: sections, swipes, search, refresh, drag

EXAMPLE
import SwiftUI

// 1) Model
struct Order: Identifiable, Hashable {
    let id: UUID
    let customer: String
    let totalCents: Int
    let status: String
}

// 2) Plain list with section + swipe actions
struct OrdersList: View {
    @State private var orders: [Order] = (1...30).map { i in
        Order(id: UUID(), customer: "Customer \(i)", totalCents: i * 1000,
              status: i.isMultiple(of: 3) ? "paid" : "new")
    }
    @State private var query = ""

    var filtered: [Order] {
        guard !query.isEmpty else { return orders }
        return orders.filter { $0.customer.localizedCaseInsensitiveContains(query) }
    }

    var body: some View {
        NavigationStack {
            List {
                Section("Open") {
                    ForEach(filtered.filter { $0.status == "new" }) { row($0) }
                }
                Section("Paid") {
                    ForEach(filtered.filter { $0.status == "paid" }) { row($0) }
                }
            }
            .listStyle(.insetGrouped)
            .navigationTitle("Orders")
            .searchable(text: $query)
            .refreshable { await refresh() }
        }
    }

    @ViewBuilder
    func row(_ o: Order) -> some View {
        NavigationLink(value: o) {
            HStack {
                VStack(alignment: .leading) {
                    Text(o.customer).font(.body.weight(.medium))
                    Text(o.status).font(.caption).foregroundStyle(.secondary)
                }
                Spacer()
                Text("\(Double(o.totalCents) / 100, specifier: "\\(\"$%.2f\")")")
                    .monospacedDigit()
            }
        }
        .swipeActions(edge: .trailing) {
            Button(role: .destructive) {
                orders.removeAll { $0.id == o.id }
            } label: { Label("Delete", systemImage: "trash") }
        }
        .swipeActions(edge: .leading) {
            Button {
                if let i = orders.firstIndex(where: { $0.id == o.id }) {
                    var copy = orders[i]; copy = Order(id: copy.id, customer: copy.customer,
                                                       totalCents: copy.totalCents, status: "paid")
                    orders[i] = copy
                }
            } label: { Label("Pay", systemImage: "checkmark") }
              .tint(.green)
        }
    }

    func refresh() async {
        try? await Task.sleep(nanoseconds: 800_000_000)
        // refresh from API here
    }
}

// 3) Reorderable list
struct ReorderableList: View {
    @State private var items = ["Apple", "Banana", "Cherry", "Date"]
    @State private var editMode: EditMode = .inactive

    var body: some View {
        NavigationStack {
            List {
                ForEach(items, id: \.self) { Text($0) }
                    .onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
                    .onDelete { offsets in items.remove(atOffsets: offsets) }
            }
            .toolbar { EditButton() }
            .environment(\.editMode, $editMode)
        }
    }
}

// 4) LazyVStack — when List does not fit (custom backgrounds, infinite scroll, etc.)
struct CustomList: View {
    let items: [Order]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                ForEach(items) { o in
                    HStack {
                        Text(o.customer)
                        Spacer()
                        Text(o.status)
                    }
                    .padding()
                    Divider()
                }
            }
        }
    }
}

// 5) Pull-to-refresh works on any List; for LazyVStack use the Scroll APIs directly.

// 6) Common pitfalls
// - Using ForEach without id when the data is not Identifiable -> diffing breaks
// - Heavy row content -> measure with the Instruments View Body recorder
// - Custom row backgrounds inside List -> use .listRowBackground / .listRowSeparator(.hidden)
// - Forgetting EditButton in toolbar when using onDelete/onMove

// 7) Decision matrix
// - Standard rows + sections + swipe actions       -> List + Section
// - Reorderable                                      -> List + .onMove + EditButton
// - Custom row appearance / background               -> List + .listRowBackground
// - Need full visual control / virtualised columns  -> LazyVStack inside ScrollView
// - Tens of thousands of rows                       -> List (it virtualises) + paginate

Why it matters

`List` does diffing, recycling, swipe actions, search and refresh for free. Reach for LazyVStack only when List style does not fit; trying to rebuild Lists features by hand is the surest way to ship a slow, bug-prone scroll on day one.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
List {
    ForEach(items) { i in
        Text(i.title)
    }
    .onDelete { idx in items.remove(atOffsets: idx) }
}
Try it Yourself »

Discussion

Loading…