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:
struct ShoppingItem: Identifiable {
let id = UUID()
var name: String
var isBought = false
}Identifiableverlangt genau eine Eigenschaft:id.UUID()erzeugt eine zufällige, weltweit einmalige Kennung, etwaE621E1F8-C36C-495A-93FC-0C247A3E6E5F. Die Wahrscheinlichkeit, dass zwei gleich sind, ist praktisch null.letsorgt dafür, dass sich dieidnie ändert.
Jetzt kann ForEach die Elemente ohne id:-Angabe unterscheiden:
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:
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.
Was passiert hier?
.onDeletesteht amForEach, nicht an derList. Nur Zeilen aus einemForEachkö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:
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
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()
}
}
}
}NavigationStackgibt 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:
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.
.tintfärbt die Aktion. Mitrole: .destructivewird 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:
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 Closuretrueliefert.firstIndex(where:)liefert die Position des ersten passenden Elements, als Optional, denn es könnte keins geben (Lektion 1.8). Mitif letpacken 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:
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
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
var id: UUID { UUID() } // ❌ berechnete EigenschaftJeder 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
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
Ü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!
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.
Ü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!
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.
Ü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
Identifiablemitlet id = UUID()macht jedes Element eindeutig und stabil erkennbar.- Dann braucht
ForEachkeineid:-Angabe mehr. .onDeleteamForEachermöglicht Löschen per Wischgeste, mitremove(atOffsets:)..onMovemitmove(fromOffsets:toOffset:)ermöglicht Verschieben im Bearbeitungsmodus.EditButton()schaltet den Bearbeitungsmodus um..swipeActionsan der Zeile bietet eigene Wischaktionen.- Elemente findest du über ihre
id:firstIndex(where:)undremoveAll(where:).
Spickzettel
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 }