Lektion 2.16 · Stufe 2 – SwiftUI-Einstieg

Identifiable, Löschen und Verschieben

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du eigene Datentypen mit Identifiable eindeutig kennzeichnen, Listeneinträge per Wischgeste löschen, per Drag & Drop neu sortieren, mit EditButton in den Bearbeitungsmodus wechseln und eigene Wischaktionen mit swipeActions anbieten.

Das Problem: Woran erkennt man einen Eintrag?

In der letzten Lektion haben wir Elemente mit id: \.self oder id: \.name gekennzeichnet. Beides hatte einen Haken:

  • Zwei Einkäufe namens „Brot“ hätten dieselbe Kennung.
  • Zwei Kontakte namens „Anna Berger“ ebenfalls.
  • Und ändert jemand den Namen eines Kontakts, ändert sich auch seine Kennung. Für SwiftUI ist das dann ein neuer Eintrag.

Stell dir vor, du wischst über die Zeile des zweiten „Brot“, um sie zu löschen, und SwiftUI löscht die erste. Oder eine Animation springt wild durch die Liste. Genau solche Fehler entstehen durch unklare Kennungen.

Was wir brauchen: eine eigene Kennung für jeden Eintrag, die garantiert einmalig ist und sich nie ändert. Wie eine Ausweisnummer.

Identifiable

Du kennst das Protokoll schon aus Lektion 1.15:

Swift
struct ShoppingItem: Identifiable {
    let id = UUID()
    var name: String
    var isBought = false
}
  • Identifiable verlangt genau eine Eigenschaft: id.
  • UUID() erzeugt eine zufällige, weltweit einmalige Kennung, etwa E621E1F8-C36C-495A-93FC-0C247A3E6E5F. Die Wahrscheinlichkeit, dass zwei gleich sind, ist praktisch null.
  • let sorgt dafür, dass sich die id nie ändert.

Jetzt kann ForEach die Elemente ohne id:-Angabe unterscheiden:

Swift
struct ShoppingList: View {
    @State private var items = [
        ShoppingItem(name: "Brot"),
        ShoppingItem(name: "Milch"),
        ShoppingItem(name: "Brot")
    ]

    var body: some View {
        List(items) { item in
            Text(item.name)
        }
    }
}

Zweimal „Brot“ ist kein Problem mehr: Jedes hat eine eigene id.

Faustregel

Jeder eigene Typ, den du in einer Liste anzeigst, sollte Identifiable sein. Mach es dir zur Gewohnheit, let id = UUID() als erste Eigenschaft zu schreiben. Ausnahme: Wenn deine Daten schon eine eindeutige Kennung haben, etwa eine ID aus einer Datenbank oder einem Server, verwende diese als id.

Löschen per Wischgeste

Für das Löschen gibt es einen Modifier an ForEach:

Swift
List {
    ForEach(items) { item in
        Text(item.name)
    }
    .onDelete { indexSet in
        items.remove(atOffsets: indexSet)
    }
}

Jetzt kann der Nutzer eine Zeile nach links wischen, und es erscheint ein roter „Löschen“-Knopf. Wischt er weiter oder tippt darauf, verschwindet die Zeile.

Erwartetes Ergebnis

Wischst du eine Zeile nach links, erscheint rechts ein roter Bereich „Löschen“. Die Zeile verschwindet mit einer Animation.

Screenshot folgt: Liste mit gewischter Zeile und Löschen-Knopf

Was passiert hier?

  • .onDelete steht am ForEach, nicht an der List. Nur Zeilen aus einem ForEach können gelöscht werden.
  • Die Closure bekommt ein IndexSet: die Positionen der zu löschenden Zeilen. Meist ist es nur eine, aber im Bearbeitungsmodus können es mehrere sein.
  • items.remove(atOffsets:) entfernt genau diese Positionen aus dem Array. Diese Methode bringt SwiftUI für Arrays mit.

Und wieder gilt: Du löschst nur aus den Daten. Die Zeile verschwindet, weil die Liste den neuen Zustand beschreibt.

Achtung

Deshalb brauchst du hier die Kurzform-Liste mit Bedacht: List(items) { ... } kennt kein .onDelete. Für Löschen und Verschieben brauchst du immer die Form mit List { ForEach(...) { ... } }.

Verschieben

Ganz ähnlich funktioniert das Neuordnen:

Swift
ForEach(items) { item in
    Text(item.name)
}
.onDelete { indexSet in
    items.remove(atOffsets: indexSet)
}
.onMove { indexSet, newOffset in
    items.move(fromOffsets: indexSet, toOffset: newOffset)
}

Die Closure bekommt die alten Positionen und die neue Zielposition. move(fromOffsets:toOffset:) erledigt den Rest.

Zum Verschieben muss die Liste aber im Bearbeitungsmodus sein. Dafür brauchst du einen EditButton.

Der Bearbeitungsmodus

Swift
struct ShoppingList: View {
    @State private var items = [
        ShoppingItem(name: "Brot"),
        ShoppingItem(name: "Milch"),
        ShoppingItem(name: "Käse")
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    Text(item.name)
                }
                .onDelete { items.remove(atOffsets: $0) }
                .onMove { items.move(fromOffsets: $0, toOffset: $1) }
            }
            .navigationTitle("Einkaufsliste")
            .toolbar {
                EditButton()
            }
        }
    }
}
  • NavigationStack gibt der Liste eine Navigationsleiste mit Titel (Lektion 2.17).
  • .toolbar { EditButton() } setzt einen „Bearbeiten“-Knopf in die Leiste (Lektion 2.18).
  • Tippt der Nutzer darauf, erscheinen links rote Minus-Symbole zum Löschen und rechts Griffe zum Verschieben. Der Knopf heißt dann „Fertig“.

Die Closures von onDelete und onMove sind hier mit $0 und $1 abgekürzt (Lektion 1.11).

Erwartetes Ergebnis

Eine Liste mit dem Titel „Einkaufsliste“ und einem „Bearbeiten“-Knopf oben rechts. Im Bearbeitungsmodus kannst du Zeilen an ihren Griffen nach oben und unten ziehen.

Eigene Wischaktionen

Löschen ist nicht die einzige sinnvolle Wischaktion. Mit .swipeActions baust du eigene, und zwar an der Zeile, nicht am ForEach:

Swift
ForEach(items) { item in
    Text(item.name)
        .swipeActions(edge: .trailing) {
            Button("Löschen", systemImage: "trash", role: .destructive) {
                delete(item)
            }
        }
        .swipeActions(edge: .leading) {
            Button("Gekauft", systemImage: "checkmark") {
                toggleBought(item)
            }
            .tint(.green)
        }
}
  • edge: .trailing: Aktionen erscheinen beim Wischen nach links (am rechten Rand). .leading: beim Wischen nach rechts.
  • Mehrere Buttons in einer Closure ergeben mehrere Aktionen nebeneinander.
  • .tint färbt die Aktion. Mit role: .destructive wird sie automatisch rot.

Achtung

Sobald du eigene swipeActions am rechten Rand anbietest, verschwindet die automatische Löschaktion von .onDelete. Willst du beides, nimm das Löschen als eigenen Button in deine swipeActions auf.

Ein Element in einem Array finden

In delete(item) und toggleBought(item) musst du das Element im Array finden. Genau dafür ist die id da:

Swift
func delete(_ item: ShoppingItem) {
    items.removeAll { $0.id == item.id }
}

func toggleBought(_ item: ShoppingItem) {
    if let index = items.firstIndex(where: { $0.id == item.id }) {
        items[index].isBought.toggle()
    }
}
  • removeAll { ... } entfernt alle Elemente, für die die Closure true liefert.
  • firstIndex(where:) liefert die Position des ersten passenden Elements, als Optional, denn es könnte keins geben (Lektion 1.8). Mit if let packen wir sie aus (Lektion 1.9).

Hier zeigt sich, warum eine eindeutige id so wertvoll ist: Mit dem Namen statt der id würdest du beim doppelten „Brot“ beide erwischen.

Mit Bindings bearbeiten

Aus der letzten Lektion kennst du ForEach($items). Mit Identifiable entfällt die id:-Angabe:

Swift
List {
    ForEach($items) { $item in
        Toggle(item.name, isOn: $item.isBought)
    }
    .onDelete { items.remove(atOffsets: $0) }
}

Jeder Toggle ändert direkt das richtige Element, und Löschen funktioniert trotzdem.

Typische Fehler

id als var

Swift
struct Item: Identifiable {
    var id = UUID()
}

Das kompiliert, ist aber riskant: Wird die id versehentlich geändert, hält SwiftUI das Element für ein neues. Nimm let.

Neue id bei jeder Erzeugung

Swift
var id: UUID { UUID() }   // ❌ berechnete Eigenschaft

Jeder Zugriff liefert eine neue Kennung. Für SwiftUI ist jede Zeile bei jeder Aktualisierung ein neues Element: Animationen springen, Eingaben gehen verloren. Die id muss gespeichert sein.

onDelete an der falschen Stelle

Swift
List(items) { item in
    Text(item.name)
}
.onDelete { ... }

Fehlermeldung

Value of type 'List<…>' has no member 'onDelete'

onDelete gibt es nur an ForEach. Nimm die Form List { ForEach(...) { ... }.onDelete { ... } }.

Verschieben ohne Bearbeitungsmodus

.onMove ist gesetzt, aber man kann nichts verschieben. Ohne EditButton bzw. aktiven Bearbeitungsmodus erscheinen keine Griffe.

FAQ

Anfänger

Was bedeutet das überhaupt?

Muss ich die id jemals selbst anzeigen? Nein. Die id ist nur für das Programm da. Nutzer sehen sie nie.

Was passiert mit der id, wenn die App neu startet? Solange die Daten nicht gespeichert werden, entstehen beim Start neue Elemente mit neuen IDs. Speicherst du sie (Stufe 3), wird die id mitgespeichert und bleibt erhalten.

Kann ich das Löschen mit einer Rückfrage absichern? Ja. Statt sofort zu löschen, merkst du dir das Element und zeigst einen Bestätigungsdialog. Das lernst du in Lektion 2.20.

Fortgeschritten

Warum verhält es sich so?

Was ist ein Key Path wie \.name? Ein Verweis auf eine Eigenschaft eines Typs, ohne einen konkreten Wert. \ShoppingItem.name bedeutet „die Eigenschaft name eines beliebigen ShoppingItem“. ForEach(items, id: \.name) sagt also: „Lies bei jedem Element name und nimm das als Kennung.“ Identifiable entspricht id: \.id.

Wie mache ich eine Mehrfachauswahl in einer Liste? Mit List(items, selection: $selection), wobei selection ein Set aus IDs ist (Lektion 1.7). Im Bearbeitungsmodus erscheinen dann Kreise zum Auswählen. So baust du etwa „mehrere löschen“.

Wie verhindere ich, dass bestimmte Zeilen gelöscht werden? Mit .deleteDisabled(true) an der Zeile, zum Beispiel .deleteDisabled(item.isLocked).

Profi

Was passiert unter der Haube?

Warum verwendet SwiftUI nicht einfach die Position als Kennung? Weil sich Positionen beim Löschen, Einfügen und Verschieben ändern. Wird Zeile 2 gelöscht, wird die alte Zeile 3 zur neuen Zeile 2. SwiftUI würde denken, Zeile 2 habe sich geändert und Zeile 3 sei gelöscht worden. Animationen und gespeicherter Zustand in den Zeilen wären dann falsch zugeordnet.

Muss eine id eine UUID sein? Nein, jeder Hashable-Typ funktioniert: Int, String oder eine eigene Struct. Wichtig ist nur, dass sie innerhalb der Liste eindeutig und über die Zeit stabil ist.

Übungen

Anfänger

Übung 1: Löschbare Liste

Mach aus der Städteliste der letzten Lektion eine Liste mit einer Identifiable-Struct City. Die Städte sollen per Wischgeste gelöscht werden können.

Lösung Übung 1Erst selbst probieren!
Swift
struct City: Identifiable {
    let id = UUID()
    let name: String
}

struct CityList: View {
    @State private var cities = ["Wien", "Graz", "Linz"].map { City(name: $0) }

    var body: some View {
        List {
            ForEach(cities) { city in
                Label(city.name, systemImage: "building.2")
            }
            .onDelete { cities.remove(atOffsets: $0) }
        }
    }
}

map (Lektion 1.11) macht aus jedem Namen eine City.

Fortgeschritten

Übung 2: Einkaufsliste mit Häkchen

Baue eine Einkaufsliste mit ShoppingItem. Jede Zeile zeigt den Namen. Gekaufte Artikel sind durchgestrichen und grau. Eine Wischgeste nach rechts markiert als gekauft bzw. nicht gekauft, eine Wischgeste nach links löscht. Außerdem lassen sich die Einträge im Bearbeitungsmodus verschieben.

Lösung Übung 2Erst selbst probieren!
Swift
struct ShoppingItem: Identifiable {
    let id = UUID()
    var name: String
    var isBought = false
}

struct ShoppingList: View {
    @State private var items = ["Brot", "Milch", "Käse", "Äpfel"].map { ShoppingItem(name: $0) }

    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    Text(item.name)
                        .strikethrough(item.isBought)
                        .foregroundStyle(item.isBought ? .secondary : .primary)
                        .swipeActions(edge: .leading) {
                            Button(item.isBought ? "Zurück" : "Gekauft", systemImage: "checkmark") {
                                toggleBought(item)
                            }
                            .tint(.green)
                        }
                        .swipeActions(edge: .trailing) {
                            Button("Löschen", systemImage: "trash", role: .destructive) {
                                items.removeAll { $0.id == item.id }
                            }
                        }
                }
                .onMove { items.move(fromOffsets: $0, toOffset: $1) }
            }
            .navigationTitle("Einkauf")
            .toolbar { EditButton() }
        }
    }

    func toggleBought(_ item: ShoppingItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].isBought.toggle()
        }
    }
}

.foregroundStyle(item.isBought ? .secondary : .primary) funktioniert, weil .secondary und .primary beide vom Typ HierarchicalShapeStyle sind.

Profi

Übung 3: Erledigt-Abschnitt

Erweitere Übung 2: Die Liste hat zwei Sections, „Noch kaufen“ und „Erledigt“. Ein Artikel wandert beim Abhaken automatisch in den anderen Abschnitt. Ist ein Abschnitt leer, wird er gar nicht angezeigt. Ganz unten gibt es einen Button „Erledigte entfernen“.

Tipp: Zwei berechnete Eigenschaften mit filter (Lektion 1.11).

Zusammenfassung

  • Identifiable mit let id = UUID() macht jedes Element eindeutig und stabil erkennbar.
  • Dann braucht ForEach keine id:-Angabe mehr.
  • .onDelete am ForEach ermöglicht Löschen per Wischgeste, mit remove(atOffsets:).
  • .onMove mit move(fromOffsets:toOffset:) ermöglicht Verschieben im Bearbeitungsmodus.
  • EditButton() schaltet den Bearbeitungsmodus um.
  • .swipeActions an der Zeile bietet eigene Wischaktionen.
  • Elemente findest du über ihre id: firstIndex(where:) und removeAll(where:).

Spickzettel

Swift
struct Item: Identifiable {
    let id = UUID()
    var name: String
}

List {
    ForEach(items) { item in
        Text(item.name)
            .swipeActions(edge: .leading) {
                Button("Markieren", systemImage: "star") { ... }.tint(.yellow)
            }
    }
    .onDelete { items.remove(atOffsets: $0) }
    .onMove { items.move(fromOffsets: $0, toOffset: $1) }
}
.toolbar { EditButton() }

items.firstIndex(where: { $0.id == item.id })
items.removeAll { $0.id == item.id }

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.