Lernziel
Nach dieser Lektion kannst du alle Zustände eines Ladevorgangs mit einem Enum sauber modellieren, für jeden Zustand eine passende Ansicht zeigen, verständliche Fehlermeldungen mit einem „Erneut versuchen“-Button anbieten und dieses Muster als wiederverwendbare Komponente bauen.
Das Problem: Die vergessenen Zustände
Eine typische erste Version einer Liste mit Daten aus dem Internet sieht so aus:
List(meals) { meal in
Text(meal.name)
}
.task {
meals = (try? await api.search("pasta")) ?? []
}Das funktioniert, wenn alles gut geht. Aber was sieht der Nutzer:
- während geladen wird? Eine leere Fläche. Ist die App kaputt?
- wenn kein Internet da ist? Eine leere Fläche. Ist die App kaputt?
- wenn es keine Treffer gibt? Eine leere Fläche. Gibt es nichts, oder hat das Laden nicht geklappt?
Drei völlig verschiedene Situationen, und alle sehen gleich aus. Das ist eine der häufigsten Schwächen in Apps, und der Unterschied zwischen einer Bastelei und einer professionellen App.
Die vier Zustände
Jeder Ladevorgang kann sich in genau einem von vier Zuständen befinden:
| Zustand | Was der Nutzer sehen sollte |
|---|---|
| Lädt | Ein Ladeindikator, damit klar ist: Es passiert etwas. |
| Fehler | Was schiefgelaufen ist, und eine Möglichkeit, es erneut zu versuchen. |
| Leer | Dass es nichts gibt, und warum, oder was man tun kann. |
| Geladen | Die Daten. |
Manchmal kommt ein fünfter dazu: Noch nicht gestartet, etwa bei einer Suche, bevor der Nutzer etwas eingegeben hat.
Der falsche Weg: viele Einzelwerte
In Lektion 3.7 haben wir es so gemacht:
var meals: [Meal] = []
var isLoading = false
var errorMessage: String?Drei unabhängige Werte erlauben acht Kombinationen, aber nur vier davon ergeben Sinn. Was bedeutet isLoading == true und eine errorMessage? Lädt es noch, oder ist es schiefgegangen? Solche Widersprüche schleichen sich leicht ein, etwa wenn man vergisst, errorMessage beim neuen Laden zurückzusetzen.
Der richtige Weg: ein Enum
Erinnerst du dich an Lektion 1.14? Enums mit Associated Values sind wie gemacht für genau diesen Fall:
enum LoadState<Value> {
case idle
case loading
case loaded(Value)
case failed(String)
}idle: noch nicht gestartetloading: lädt geradeloaded(Value): fertig, mit den Datenfailed(String): fehlgeschlagen, mit einer Meldung
Das <Value> ist ein Generic (Lektion 1.18): Dasselbe Enum funktioniert für eine Liste von Rezepten, ein einzelnes Rezept oder alles andere.
Der Zustand ist immer genau einer der vier Fälle. Widersprüche sind unmöglich. Und die Daten gibt es nur im Fall loaded, man kann also gar nicht versehentlich alte Daten anzeigen, während ein Fehler vorliegt.
Ein ViewModel mit Zustand
@MainActor
@Observable
class MealListViewModel {
var state: LoadState<[Meal]> = .idle
private let api = MealAPI()
func load(query: String) async {
state = .loading
do {
let meals = try await api.search(query)
state = .loaded(meals)
} catch {
state = .failed("Die Rezepte konnten nicht geladen werden. Prüfe deine Internetverbindung.")
}
}
}Jede Änderung ist eine einzige Zuweisung an state. Kein Zurücksetzen von drei Werten, kein Vergessen.
Die View: ein switch
Die View beschreibt für jeden Zustand, was zu sehen ist (Lektion 2.1):
struct MealListView: View {
@State private var viewModel = MealListViewModel()
var body: some View {
NavigationStack {
content
.navigationTitle("Pasta")
.task {
await viewModel.load(query: "pasta")
}
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView("Rezepte werden geladen…")
case .failed(let message):
ContentUnavailableView {
Label("Fehler", systemImage: "wifi.exclamationmark")
} description: {
Text(message)
} actions: {
Button("Erneut versuchen") {
Task { await viewModel.load(query: "pasta") }
}
.buttonStyle(.borderedProminent)
}
case .loaded(let meals) where meals.isEmpty:
ContentUnavailableView(
"Keine Rezepte",
systemImage: "fork.knife",
description: Text("Für diese Suche gibt es leider keine Treffer.")
)
case .loaded(let meals):
List(meals) { meal in
Text(meal.name)
}
}
}
}Ein paar Details:
@ViewBuilderan der berechneten Eigenschaftcontenterlaubt einenswitch, der in jedem Fall eine andere View liefert (Lektion 2.21).case .idle, .loading:behandelt zwei Fälle gleich.case .loaded(let meals) where meals.isEmpty:ist ein Fall mit Bedingung (Lektion 1.4, 🟡-FAQ). Er muss vor dem allgemeinen.loadedstehen, weil Swift den ersten passenden Fall nimmt.ContentUnavailableViewkennst du aus Lektion 2.15. Mit der ausführlichen Form kannst du zusätzlich Aktionen wie einen Button anbieten.
Erwartetes Ergebnis
Erst ein Ladekreis mit Text, dann die Liste. Schaltest du den Mac offline und startest neu, erscheint ein großes WLAN-Symbol, die Fehlermeldung und ein Button „Erneut versuchen“. Schalte das Internet wieder ein und tippe darauf, und die Liste erscheint.
Zum Testen
Fehler und Leerzustände kommen beim normalen Entwickeln selten vor, deshalb werden sie oft nie getestet. Mach es dir leicht: Leg Previews an, die das ViewModel mit einem festen Zustand füllen, etwa .failed("Testfehler") oder .loaded([]). So siehst du alle Zustände auf einen Blick (Lektion 2.3).
Gute Fehlermeldungen
Eine Fehlermeldung ist nur hilfreich, wenn der Nutzer etwas damit anfangen kann:
| ❌ Schlecht | ✅ Besser |
|---|---|
| „Error -1009“ | „Keine Internetverbindung. Prüfe WLAN oder mobile Daten.“ |
| „Decoding failed“ | „Die Daten konnten nicht gelesen werden. Versuch es später erneut.“ |
| „Fehler“ | „Die Rezepte konnten nicht geladen werden.“ |
Drei Regeln:
- Was ist passiert? In Alltagssprache, ohne Fachbegriffe.
- Was kann der Nutzer tun? Erneut versuchen, Verbindung prüfen, später wiederkommen.
- Eine Aktion anbieten, wenn es eine gibt, etwa „Erneut versuchen“.
Mit URLError kannst du typische Netzwerkprobleme unterscheiden:
private func message(for error: Error) -> String {
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet:
return "Keine Internetverbindung. Prüfe WLAN oder mobile Daten."
case .timedOut:
return "Der Server antwortet nicht. Versuch es gleich noch einmal."
default:
return "Es gab ein Problem mit der Verbindung."
}
}
return "Die Rezepte konnten nicht geladen werden."
}Achtung
Gib dem Nutzer nie die rohe technische Fehlermeldung aus error.localizedDescription von Systemfehlern oder DecodingError. Sie ist oft auf Englisch, technisch und verwirrend. Für dich als Entwickler gehört sie mit print(error) in die Konsole.
Daten behalten beim Neuladen
Ein Sonderfall: Lädt der Nutzer eine Liste neu, die schon angezeigt wird, soll sie nicht durch einen Ladekreis ersetzt werden. Das wirkt unruhig. Besser: Die alten Daten bleiben sichtbar, bis die neuen da sind.
Dafür prüfst du vor dem Laden, ob schon Daten da sind:
func load(query: String) async {
if case .loaded = state {
// Daten bleiben sichtbar, kein Ladezustand
} else {
state = .loading
}
// … laden wie bisher
}if case .loaded = state prüft, ob der Zustand irgendein .loaded ist, ohne die Daten auszupacken (Lektion 1.14, 🟡-FAQ). In der nächsten Lektion kommt dazu „Ziehen zum Aktualisieren“, das genau so arbeitet.
Eine wiederverwendbare Komponente
Das switch-Muster wiederholt sich in jeder App, die Daten lädt. Mit einem generischen Container (Lektion 2.21) baust du es einmal:
struct LoadStateView<Value, Content: View>: View {
let state: LoadState<Value>
let retry: () -> Void
@ViewBuilder let content: (Value) -> Content
var body: some View {
switch state {
case .idle, .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("Fehler", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Erneut versuchen", action: retry)
.buttonStyle(.borderedProminent)
}
case .loaded(let value):
content(value)
}
}
}Die Verwendung wird dann sehr kurz:
LoadStateView(state: viewModel.state) {
Task { await viewModel.load(query: "pasta") }
} content: { meals in
List(meals) { Text($0.name) }
}Laden und Fehler sind überall einheitlich, und jede View beschreibt nur noch, was sie mit den geladenen Daten macht. Den Leerzustand kannst du im content selbst behandeln, weil er von den Daten abhängt.
Typische Fehler
Fehlender case
Fehlermeldung
Switch must be exhaustive
Ein Zustand wird nicht behandelt. Das ist der große Vorteil des Enums: Der Compiler zwingt dich, an jeden Fall zu denken.
where-Fall nach dem allgemeinen Fall
case .loaded(let meals):
List(meals) { … }
case .loaded(let meals) where meals.isEmpty: // wird nie erreichtSwift nimmt den ersten passenden Fall. Spezielle Fälle gehören vor die allgemeinen. Xcode warnt hier mit Case is already handled by previous patterns.
Ladezustand bleibt hängen
Passiert mit Einzelwerten, wenn bei einem Fehler isLoading = false vergessen wird. Mit dem Enum kann das nicht passieren: Jeder Weg endet in .loaded oder .failed.
FAQ
Anfänger
Was bedeutet das überhaupt?Muss ich wirklich jeden Zustand gestalten? Ja, zumindest Laden, Fehler und Leer. Es dauert nur ein paar Minuten, macht aber einen enormen Unterschied für den Eindruck der App. Nutzer verzeihen Fehler, aber keine leeren, rätselhaften Bildschirme.
Wie lange sollte ich warten, bevor ich einen Ladekreis zeige? Für die meisten Fälle sofort. Bei sehr schnellen Antworten kann ein kurz aufblitzender Ladekreis aber unruhig wirken. Manche Apps zeigen ihn deshalb erst nach etwa einer Viertelsekunde. Das ist eine Verfeinerung für später.
Fortgeschritten
Warum verhält es sich so?Wie zeige ich einen Fehler an, ohne die alten Daten zu verlieren?
Dann reicht das einfache Enum nicht. Eine Möglichkeit: Das ViewModel hat zusätzlich zu state eine separate errorMessage, die als Alert oder Banner über der weiterhin sichtbaren Liste erscheint (Lektion 2.20). So machen es viele Apps beim Aktualisieren.
Was ist ein Skeleton Screen?
Statt eines Ladekreises zeigt man graue Platzhalter in Form der späteren Inhalte. In SwiftUI geht das elegant mit .redacted(reason: .placeholder) auf einer View mit Beispieldaten. Das wirkt oft ruhiger, weil sich das Layout beim Laden nicht verändert.
Profi
Was passiert unter der Haube?Sollte LoadState den Fehler selbst speichern statt eines Strings?
Beides ist üblich. Ein Error im Zustand behält alle Informationen, etwa für Protokollierung, und die View entscheidet über den Text. Ein String hält die View einfacher und den Zustand leichter vergleichbar, denn Error ist nicht Equatable. Für App-Code ist ein String oder ein eigenes Equatable-Fehler-Enum oft praktischer, weil sich dann auch .animation(_:value:) auf den Zustand anwenden lässt.
Wie verhindere ich, dass ein alter Ladevorgang einen neueren überschreibt?
Kommt die Antwort einer alten Anfrage nach der einer neuen an, würde sie den Zustand überschreiben. Mit .task(id:) (Lektion 3.7) wird die alte Aufgabe abgebrochen. Bei manuell gestarteten Aufgaben merkst du dir den laufenden Task und brichst ihn ab, bevor du einen neuen startest.
Übungen
Übung 1: Previews für alle Zustände
Ergänze MealListViewModel um einen Initializer mit einem Startzustand und leg für MealListView vier Previews an: Laden, Fehler, leer und mit zwei Beispiel-Rezepten. Dafür muss die View das ViewModel von außen bekommen können.
Übung 2: Verständliche Meldungen
Bau die Funktion message(for:) aus dieser Lektion in das ViewModel ein und ergänze Fälle für APIError.badStatus und DecodingError. Teste jeden Fall, indem du die Basisadresse oder das Modell absichtlich änderst.
Übung 3: LoadStateView mit Leerzustand
Erweitere LoadStateView um einen optionalen Parameter isEmpty: (Value) -> Bool und eine View für den Leerzustand. Ist isEmpty(value) true, wird statt content die Leeransicht gezeigt. Verwende die Komponente für die Rezeptliste.
Zusammenfassung
- Jeder Ladevorgang hat mehrere Zustände: noch nicht gestartet, lädt, Fehler, leer, geladen.
- Ein Enum mit Associated Values wie
LoadState<Value>modelliert sie widerspruchsfrei. - Die View beschreibt jeden Zustand mit einem
switch. Spezielle Fälle mitwheregehören nach vorn. ContentUnavailableViewmitactions:für Fehler und Leerzustände.- Gute Fehlermeldungen sagen, was passiert ist und was man tun kann.
- Eine generische
LoadStateViewmacht das Muster überall wiederverwendbar.
Spickzettel
enum LoadState<Value> {
case idle, loading
case loaded(Value)
case failed(String)
}
var state: LoadState<[Meal]> = .idle
switch viewModel.state {
case .idle, .loading:
ProgressView()
case .failed(let message):
ContentUnavailableView { Label("Fehler", systemImage: "wifi.exclamationmark") }
description: { Text(message) }
actions: { Button("Erneut versuchen") { retry() } }
case .loaded(let items) where items.isEmpty:
ContentUnavailableView("Leer", systemImage: "tray")
case .loaded(let items):
List(items) { ... }
}