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
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:
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:
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:
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 aus | eine View: beschreibt mehrere Views |
| in Funktionen und Aktionen | in body und anderen View-Beschreibungen |
| läuft sofort, einmal | wird von SwiftUI bei Bedarf ausgewertet |
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:
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.
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:
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:
List {
Section {
Label("Alle Kontakte", systemImage: "person.2")
}
Section("Favoriten") {
ForEach(favorites, id: \.self) { name in
Text(name)
}
}
}Listenstile
List { ... }
.listStyle(.plain)| Stil | Aussehen |
|---|---|
.automatic / .insetGrouped | abgerundete Gruppen auf grauem Grund (Standard auf dem iPhone) |
.plain | Zeilen über die ganze Breite, kein Gruppenhintergrund |
.grouped | Gruppen über die ganze Breite, ohne abgerundete Ecken |
.sidebar | wie in Seitenleisten, mit einklappbaren Abschnitten |
Weitere nützliche Modifier:
List { ... }
.listRowSeparator(.hidden) // an einer Zeile: Trennlinie ausblenden
.listRowBackground(Color.yellow) // an einer Zeile: eigener Hintergrund
.scrollContentBackground(.hidden) // an der Liste: grauen Hintergrund entfernenLeere Listen
Was zeigt eine Liste ohne Einträge? Nichts, nur eine graue Fläche. Das verwirrt Nutzer. Besser:
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:
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:
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
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
Ü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!
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")
}
}
}Ü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!
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.
Ü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
Listzeigt Daten als scrollbare Liste und erzeugt nur sichtbare Zeilen.ForEacherzeugt eine View pro Element. Es ist eine View, keine Schleife.id:sagt, woran Elemente erkannt werden.\.selfnur bei eindeutigen Werten.Sectiongliedert Listen, feste und dynamische Zeilen lassen sich mischen..listStylewählt das Aussehen.ContentUnavailableViewfüllt leere Listen.ForEach($items) { $item in ... }liefert für jede Zeile ein Binding.
Spickzettel
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("…"))