Lektion 3.11 · Stufe 3 – Fortgeschritten

Lade-, Fehler- und Leerzustände

ca. 15 Minuten Lesezeit

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:

Swift
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:

ZustandWas der Nutzer sehen sollte
LädtEin Ladeindikator, damit klar ist: Es passiert etwas.
FehlerWas schiefgelaufen ist, und eine Möglichkeit, es erneut zu versuchen.
LeerDass es nichts gibt, und warum, oder was man tun kann.
GeladenDie 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:

Swift
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:

Swift
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}
  • idle: noch nicht gestartet
  • loading: lädt gerade
  • loaded(Value): fertig, mit den Daten
  • failed(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

Swift
@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):

Swift
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:

  • @ViewBuilder an der berechneten Eigenschaft content erlaubt einen switch, 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 .loaded stehen, weil Swift den ersten passenden Fall nimmt.
  • ContentUnavailableView kennst 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.

Screenshot folgt: Die drei Zustände nebeneinander: Laden, Fehler, Liste

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:

  1. Was ist passiert? In Alltagssprache, ohne Fachbegriffe.
  2. Was kann der Nutzer tun? Erneut versuchen, Verbindung prüfen, später wiederkommen.
  3. Eine Aktion anbieten, wenn es eine gibt, etwa „Erneut versuchen“.

Mit URLError kannst du typische Netzwerkprobleme unterscheiden:

Swift
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:

Swift
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:

Swift
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:

Swift
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

Swift
case .loaded(let meals):
    List(meals) { … }
case .loaded(let meals) where meals.isEmpty:   // wird nie erreicht

Swift 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

Anfänger

Ü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.

Fortgeschritten

Ü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.

Profi

Ü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 mit where gehören nach vorn.
  • ContentUnavailableView mit actions: für Fehler und Leerzustände.
  • Gute Fehlermeldungen sagen, was passiert ist und was man tun kann.
  • Eine generische LoadStateView macht das Muster überall wiederverwendbar.

Spickzettel

Swift
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) { ... }
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.