Skip to content

SwiftUI Lists and Grids

★★★★★ Intermediate

List, ForEach, LazyVGrid, and ScrollView are the primary containers for displaying collections of data in SwiftUI. This entry covers dynamic lists, grid layouts, swipe-to-delete, and the differences between scrollable containers.

Key Facts

  • List displays scrollable rows with built-in separators and swipe-to-delete
  • ForEach generates views from a collection but is not scrollable by itself
  • Items in ForEach/List must conform to Identifiable or provide id: parameter
  • LazyVGrid creates scrollable grid layouts with flexible or fixed columns
  • ScrollView provides custom scrollable layout without List's built-in features
  • Range syntax in ForEach: only half-open ranges (0..<5), not closed ranges (0...4)

Patterns

Static List

List {
    Text("Hello")
    Circle().frame(height: 40)
    Text("Hi")
}

Dynamic List from Array

struct ContentView: View {
    var dogs = ["Fido", "Sarah", "Billy"]

    var body: some View {
        List(dogs, id: \.self) { dog in
            Text(dog)
        }
    }
}

id: \.self uses the value itself as identifier. Works for String, Int, etc.

List with Identifiable Model

class JournalEntry: Identifiable {
    var id = UUID()
    var title: String
    var text: String
}

List(journalEntries) { entry in
    Text(entry.title)
}

Identifiable conformance provides auto-generated id property.

ForEach Inside List (with onDelete)

List {
    ForEach(entries) { entry in
        Text(entry.title)
    }
    .onDelete { indices in
        entries.remove(atOffsets: indices)
    }
}

ForEach Variants

// Range-based (underscore when not using index)
ForEach(0..<5) { _ in
    CurrencyIcon(currency: .copperPenny)
}

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

// Collection-based with Identifiable
ForEach(journalEntries) { entry in
    Text(entry.title)
}

// Non-Identifiable with id parameter
ForEach(predator.movies, id: \.self) { movie in
    Text(movie)
}

// Enum cases
ForEach(Currency.allCases, id: \.self) { currency in
    CurrencyIcon(currency: currency)
}

LazyVGrid

let columns = [GridItem(), GridItem(), GridItem()]   // 3 flexible columns

LazyVGrid(columns: columns) {
    ForEach(currencies, id: \.self) { currency in
        CurrencyIcon(currency: currency)
    }
}
.padding()

Column types: - GridItem() - flexible (default), fills available space - GridItem(.fixed(100)) - fixed width - Column count = number of GridItem in array

ScrollView vs List

// List - built-in rows, separators, swipe-to-delete, selection
List(items) { item in Text(item.name) }

// ScrollView - custom layout, no separators
ScrollView {
    VStack {
        ForEach(items) { item in Text(item.name) }
    }
}

ScrollView content starts at top. Use ScrollView for full custom layout; use List for standard rows.

ScrollViewReader (Programmatic Scrolling)

ScrollViewReader { proxy in
    ScrollView {
        ForEach(items) { item in
            Text(item.name).id(item.id)
        }
    }
    Button("Scroll to last") {
        proxy.scrollTo(items.last?.id, anchor: .bottom)
    }
}

Sort, Filter, and Search Pattern

@Observable
class Predators {
    var apexPredators: [ApexPredator] = []
    var allApexPredators: [ApexPredator] = []   // master list, never modified

    func sortByName(alphabetical: Bool) {
        apexPredators.sort { pred1, pred2 in
            alphabetical ? pred1.name < pred2.name : pred1.id < pred2.id
        }
    }

    func filterBy(_ type: APType?) {
        if let type {
            apexPredators = allApexPredators.filter { $0.type == type }
        } else {
            apexPredators = allApexPredators
        }
    }

    func search(for text: String) -> [ApexPredator] {
        if text.isEmpty {
            return apexPredators
        } else {
            return apexPredators.filter {
                $0.name.localizedCaseInsensitiveContains(text)
            }
        }
    }
}

Critical: always filter from allApexPredators (master list), not from the already-filtered apexPredators. Otherwise, applying a second filter produces empty results.

Animating List Changes

List { ... }
    .animation(.default, value: predators.apexPredators)
List {
    Section {
        ForEach(pokedex) { pokemon in ... }
    } footer: {
        if pokedex.count < 151 {
            ContentUnavailableView { ... }
        }
    }
}

Gotchas

  • ForEach only accepts half-open ranges (0..<5), not closed ranges (0...4)
  • id: \.self works when values are unique - duplicate values cause undefined behavior
  • List automatically provides Identifiable-like behavior but still requires conformance on model types
  • When filtering, always filter from the original master list, not from an already-filtered copy
  • LazyVGrid renders cells lazily (only when visible) - good for performance with large datasets
  • ScrollView fills all available space by default, unlike VStack which only takes needed space
  • .onDelete only works with ForEach inside a List, not with List(items) directly

See Also