Lektion 2.15 · Stufe 2 – SwiftUI-Einstieg

Listen und ForEach

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du Daten mit List und ForEach als scrollbare Liste darstellen, Listen mit Abschnitten gliedern, Listenstile wählen, eigene Zeilen gestalten und den Unterschied zwischen ForEach und einer for-Schleife erklären.

Das Problem: Daten von beliebiger Länge

Bisher hattest du immer eine feste Anzahl von Views: drei Kontaktzeilen, vier Kaffeegrößen. Echte Apps zeigen aber Daten, deren Menge du beim Programmieren nicht kennst: Nachrichten, Kontakte, Einkäufe, Aufgaben. Mal sind es zwei, mal zweitausend.

Schreibst du jede Zeile von Hand, ist das unmöglich. Du brauchst eine Möglichkeit zu sagen: „Zeig für jedes Element in diesem Array eine Zeile.“

Die einfachste Liste

Swift
List {
    Text("Äpfel")
    Text("Brot")
    Text("Milch")
}

Erwartetes Ergebnis

Drei Zeilen in einer gruppierten, weißen Fläche auf grauem Hintergrund, durch feine Linien getrennt. Die Liste scrollt, wenn sie länger wird als der Bildschirm.

List sieht aus wie ein Form aus der letzten Lektion. Der Unterschied liegt im Zweck: List ist für Daten, also viele gleichartige Einträge.

ForEach: Eine View pro Element

Jetzt mit einem Array:

Swift
struct ShoppingList: View {
    let items = ["Äpfel", "Brot", "Milch", "Käse", "Kaffee"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
        }
    }
}

ForEach erzeugt für jedes Element des Arrays eine View. Die Closure bekommt das Element und beschreibt, wie seine Zeile aussieht. Kommt ein sechstes Element dazu, erscheint automatisch eine sechste Zeile.

Was bedeutet id: \.self?

SwiftUI muss jede Zeile eindeutig erkennen können. Warum? Wird ein Element gelöscht oder verschoben, muss SwiftUI wissen, welche Zeile verschwindet, um sie richtig zu animieren und ihren Zustand nicht zu verwechseln.

Mit id: sagst du, woran man ein Element erkennt. \.self heißt: „am Element selbst“, hier also am Text. Das funktioniert, solange jeder Text nur einmal vorkommt.

\.self ist ein sogenannter Key Path, ein Verweis auf eine Eigenschaft. \.name würde auf die Eigenschaft name verweisen. Mehr dazu in der nächsten Lektion.

Achtung

Bei id: \.self müssen alle Elemente verschieden sein. Steht zweimal „Brot“ im Array, haben zwei Zeilen dieselbe Kennung, und SwiftUI kann sie nicht unterscheiden. Das führt zu seltsamen Fehlern beim Löschen und Animieren. Die saubere Lösung, Identifiable, lernst du in Lektion 2.16.

Die Kurzform

Bei einer Liste, die nur aus ForEach besteht, kannst du das Array direkt an List übergeben:

Swift
List(items, id: \.self) { item in
    Text(item)
}

Das ist dasselbe, nur kürzer. Die lange Form mit ForEach brauchst du, sobald die Liste mehr enthält, etwa Abschnitte oder feste Zeilen.

Bereiche

ForEach funktioniert auch mit Zahlenbereichen:

Swift
ForEach(1...5, id: \.self) { number in
    Text("Zeile \(number)")
}

Bei halboffenen Bereichen wie 0..<5 darfst du id: sogar weglassen: ForEach(0..<5) { ... }.

ForEach ist keine Schleife

ForEach sieht aus wie eine for-Schleife aus Lektion 1.5, ist aber etwas anderes:

for item in items { ... }ForEach(items) { item in ... }
eine Anweisung: führt Code auseine View: beschreibt mehrere Views
in Funktionen und Aktionenin body und anderen View-Beschreibungen
läuft sofort, einmalwird von SwiftUI bei Bedarf ausgewertet
Swift
var body: some View {
    VStack {
        for item in items {       // ❌ geht nicht in body
            Text(item)
        }
    }
}

Fehlermeldung

Closure containing control flow statement cannot be used with result builder 'ViewBuilder'

In body steht eine Beschreibung, keine Befehle (Lektion 2.1). Deshalb gibt es dort ForEach statt for.

Eigene Zeilen gestalten

Die Zeilen einer Liste können beliebige Views sein. Mit Structs aus Stufe 1 wird es richtig praktisch:

Swift
struct Contact {
    let name: String
    let phone: String
    let isFavorite: Bool
}

struct ContactList: View {
    let contacts = [
        Contact(name: "Anna Berger", phone: "+43 664 1234567", isFavorite: true),
        Contact(name: "Ben Huber", phone: "+43 676 7654321", isFavorite: false),
        Contact(name: "Clara Steiner", phone: "+43 699 1112223", isFavorite: true)
    ]

    var body: some View {
        List(contacts, id: \.name) { contact in
            HStack {
                Image(systemName: "person.circle.fill")
                    .font(.title)
                    .foregroundStyle(.blue)

                VStack(alignment: .leading) {
                    Text(contact.name)
                        .font(.headline)
                    Text(contact.phone)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                if contact.isFavorite {
                    Image(systemName: "star.fill")
                        .foregroundStyle(.yellow)
                }
            }
        }
    }
}

Erwartetes Ergebnis

Drei Kontaktzeilen mit Symbol, Name und Nummer. Anna und Clara haben rechts einen gelben Stern.

Screenshot folgt: Kontaktliste mit eigenen Zeilen

Hier verwenden wir id: \.name: Jeder Kontakt wird an seinem Namen erkannt. Das ist ein Kompromiss. Zwei Personen mit gleichem Namen wären ein Problem. Auch das löst die nächste Lektion.

Tipp

Für Zeilen mit Symbol und Text ist Label oft die eleganteste Wahl: Label(contact.name, systemImage: "person"). In Listen richtet SwiftUI die Symbole aller Zeilen automatisch sauber untereinander aus.

Abschnitte

Wie in Formularen gliedern Sections eine Liste:

Swift
List {
    Section("Obst & Gemüse") {
        ForEach(produce, id: \.self) { item in
            Text(item)
        }
    }
    Section("Milchprodukte") {
        ForEach(dairy, id: \.self) { item in
            Text(item)
        }
    }
}

Und weil eine Liste eine ganz normale Beschreibung ist, kannst du feste Zeilen und dynamische mischen:

Swift
List {
    Section {
        Label("Alle Kontakte", systemImage: "person.2")
    }
    Section("Favoriten") {
        ForEach(favorites, id: \.self) { name in
            Text(name)
        }
    }
}

Listenstile

Swift
List { ... }
    .listStyle(.plain)
StilAussehen
.automatic / .insetGroupedabgerundete Gruppen auf grauem Grund (Standard auf dem iPhone)
.plainZeilen über die ganze Breite, kein Gruppenhintergrund
.groupedGruppen über die ganze Breite, ohne abgerundete Ecken
.sidebarwie in Seitenleisten, mit einklappbaren Abschnitten

Weitere nützliche Modifier:

Swift
List { ... }
    .listRowSeparator(.hidden)          // an einer Zeile: Trennlinie ausblenden
    .listRowBackground(Color.yellow)    // an einer Zeile: eigener Hintergrund
    .scrollContentBackground(.hidden)   // an der Liste: grauen Hintergrund entfernen

Leere Listen

Was zeigt eine Liste ohne Einträge? Nichts, nur eine graue Fläche. Das verwirrt Nutzer. Besser:

Swift
var body: some View {
    if items.isEmpty {
        ContentUnavailableView(
            "Keine Einträge",
            systemImage: "cart",
            description: Text("Füge deinen ersten Artikel hinzu.")
        )
    } else {
        List(items, id: \.self) { item in
            Text(item)
        }
    }
}

ContentUnavailableView zeigt ein großes Symbol, einen Titel und eine Beschreibung, im Stil der Systemapps. Es gibt sogar eine fertige Variante für leere Suchergebnisse: ContentUnavailableView.search.

Listen mit Zustand

Bisher waren die Daten Konstanten. Mit @State (Lektion 2.9) kann sich die Liste ändern:

Swift
struct TaskList: View {
    @State private var tasks = ["Einkaufen", "Sport"]
    @State private var newTask = ""

    var body: some View {
        List {
            Section {
                HStack {
                    TextField("Neue Aufgabe", text: $newTask)
                    Button("Hinzufügen") {
                        tasks.append(newTask)
                        newTask = ""
                    }
                    .disabled(newTask.isEmpty)
                }
            }
            Section("Aufgaben") {
                ForEach(tasks, id: \.self) { task in
                    Text(task)
                }
            }
        }
    }
}

Tippst du eine Aufgabe ein und drückst „Hinzufügen“, wird sie an das Array angehängt, und die Liste zeigt sofort eine neue Zeile. Du hast nirgends gesagt „füge eine Zeile ein“. Du hast nur die Daten geändert (UI = f(State)).

Bindings für jede Zeile

Soll jede Zeile ihr Element ändern können, etwa über einen Toggle, gibt es eine besondere Schreibweise:

Swift
struct Todo {
    var title: String
    var isDone = false
}

@State private var todos = [Todo(title: "Einkaufen"), Todo(title: "Sport")]

List {
    ForEach($todos, id: \.title) { $todo in
        Toggle(todo.title, isOn: $todo.isDone)
    }
}

ForEach($todos ...) mit $ liefert für jedes Element ein Binding (Lektion 2.10). In der Closure heißt der Parameter dann $todo: Mit todo liest du, mit $todo.isDone bekommst du die Verbindung für den Toggle. So ändert jeder Schalter direkt das richtige Element im Array.

Typische Fehler

for statt ForEach

Fehlermeldung

Closure containing control flow statement cannot be used with result builder 'ViewBuilder'

In body heißt es ForEach, nicht for.

id fehlt

Swift
ForEach(items) { item in
    Text(item)
}

Fehlermeldung

Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'String' conform to 'Identifiable'

ForEach muss wissen, wie es Elemente unterscheidet. Gib id: \.self an oder mach den Typ Identifiable (nächste Lektion).

Doppelte IDs

Achtung

In der Konsole: ForEach<…>: the ID … occurs multiple times within the collection, this will give undefined results!

Zwei Elemente haben dieselbe Kennung. Mach die Kennung eindeutig.

Liste in einer ScrollView

Wie beim Form: Eine List scrollt selbst. In einer ScrollView kann sie auf null Höhe schrumpfen.

FAQ

Anfänger

Was bedeutet das überhaupt?

Wie viele Zeilen kann eine Liste haben? Praktisch unbegrenzt. List erzeugt nur die Zeilen, die gerade sichtbar sind, und verwendet sie beim Scrollen wieder. Auch Tausende Einträge scrollen flüssig.

Was ist der Unterschied zwischen List und VStack mit ForEach? Ein VStack erzeugt alle Views sofort und scrollt nicht von selbst. Eine List erzeugt nur sichtbare Zeilen, scrollt, und bringt Trennlinien, Wischgesten und Bearbeitungsmodus mit. Für Daten nimm List.

Kann ich eine Zeile antippbar machen? Ja: Mach die Zeile zu einem Button oder, für eine Detailseite, zu einem NavigationLink (Lektion 2.17). Die ganze Zeile reagiert dann auf Tippen.

Fortgeschritten

Warum verhält es sich so?

Wie bekomme ich zusätzlich den Index eines Elements? Mit ForEach(Array(items.enumerated()), id: \.offset) { index, item in ... }. Verwende das aber nur, wenn du die Position wirklich brauchst, etwa für eine Nummerierung. Als Kennung ist der Index ungeeignet, weil er sich beim Löschen und Verschieben ändert.

Wie aktualisiere ich eine Liste per „Herunterziehen“? Mit .refreshable { ... }. Das ist eine asynchrone Aktion und gehört zu Lektion 3.12.

Profi

Was passiert unter der Haube?

Wie erkennt ForEach Änderungen? ForEach vergleicht die Kennungen der alten und der neuen Daten. Neue IDs werden eingefügt, verschwundene entfernt, gleiche bleiben bestehen und behalten ihren Zustand. Deshalb sind stabile, eindeutige IDs so wichtig: Sie sind die Identität jeder Zeile. Mehr dazu in Lektion 4.1.

Warum ist ForEach mit einem Bereich ohne id nur bei 0..<n erlaubt? Die Variante für Range<Int> geht davon aus, dass sich der Bereich nicht ändert, und verwendet die Zahlen selbst als Kennung. Ändert sich der Bereich doch, gibt Xcode eine Warnung aus. Für veränderliche Bereiche nimm id: \.self.

Übungen

Anfänger

Übung 1: Städteliste

Zeig ein Array mit fünf Städten als Liste. Jede Zeile ist ein Label mit dem Stadtnamen und dem Symbol building.2.

Lösung Übung 1Erst selbst probieren!
Swift
struct CityList: View {
    let cities = ["Wien", "Graz", "Linz", "Salzburg", "Innsbruck"]

    var body: some View {
        List(cities, id: \.self) { city in
            Label(city, systemImage: "building.2")
        }
    }
}
Fortgeschritten

Übung 2: Länder nach Kontinent

Leg ein Dictionary [String: [String]] an, das Kontinente auf Länder abbildet. Zeig für jeden Kontinent eine Section mit den Ländern. Die Kontinente sollen alphabetisch sortiert sein.

Tipp: data.keys.sorted() (Lektion 1.7).

Lösung Übung 2Erst selbst probieren!
Swift
struct CountryList: View {
    let data = [
        "Europa": ["Österreich", "Italien", "Frankreich"],
        "Asien": ["Japan", "Indien"],
        "Afrika": ["Kenia", "Ghana", "Marokko"]
    ]

    var body: some View {
        List {
            ForEach(data.keys.sorted(), id: \.self) { continent in
                Section(continent) {
                    ForEach(data[continent, default: []], id: \.self) { country in
                        Text(country)
                    }
                }
            }
        }
    }
}

ForEach darf ineinander verschachtelt werden. Ein äußeres für die Abschnitte, ein inneres für die Zeilen.

Profi

Übung 3: Packliste

Baue eine Packliste mit einer Struct PackItem (name, isPacked). Jede Zeile hat einen Toggle, der direkt das Element ändert. Über der Liste steht „3 von 8 eingepackt“. Ist alles eingepackt, erscheint stattdessen eine ContentUnavailableView mit „Alles eingepackt!“ und dem Symbol suitcase.fill.

Zusammenfassung

  • List zeigt Daten als scrollbare Liste und erzeugt nur sichtbare Zeilen.
  • ForEach erzeugt eine View pro Element. Es ist eine View, keine Schleife.
  • id: sagt, woran Elemente erkannt werden. \.self nur bei eindeutigen Werten.
  • Section gliedert Listen, feste und dynamische Zeilen lassen sich mischen.
  • .listStyle wählt das Aussehen. ContentUnavailableView füllt leere Listen.
  • ForEach($items) { $item in ... } liefert für jede Zeile ein Binding.

Spickzettel

Swift
List(items, id: \.self) { item in Text(item) }

List {
    Section("Titel") {
        ForEach(items, id: \.self) { item in Text(item) }
    }
}
.listStyle(.plain)

ForEach($todos, id: \.title) { $todo in
    Toggle(todo.title, isOn: $todo.isDone)
}

ContentUnavailableView("Leer", systemImage: "tray", description: Text("…"))

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.