Lektion 3.20 · Stufe 3 – Fortgeschritten

Programmatische Navigation mit NavigationPath

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du per Code navigieren: Bildschirme öffnen, ohne dass der Nutzer tippt, mehrere Ebenen auf einmal zurückspringen und den Navigationszustand in einem Router-Objekt bündeln. Du kennst den Unterschied zwischen einem Array als Pfad und NavigationPath.

Das Problem: Navigation, die nicht vom Tippen kommt

Bisher hat sich die Navigation immer durch einen Tipp des Nutzers bewegt: Er tippt auf einen NavigationLink, eine neue Seite erscheint. Er tippt auf „Zurück“, sie verschwindet.

In echten Apps soll die Navigation aber oft vom Code ausgehen:

  • Nach dem Speichern eines neuen Eintrags soll direkt dessen Detailseite erscheinen.
  • Nach einem Kauf über drei Bildschirme soll ein Button zurück ganz an den Anfang führen.
  • Eine Benachrichtigung soll die App an einer bestimmten Stelle öffnen (Lektion 3.22).
  • Ein Zufallsrezept soll erscheinen, sobald es geladen ist. Das kennst du schon aus Projekt D.

Dafür muss der Navigationszustand irgendwo stehen, wo dein Code ihn lesen und ändern kann.

Der Pfad als Zustand

Ein NavigationStack kann einen Pfad bekommen: eine Liste der Werte, deren Seiten gerade übereinander liegen.

Swift
struct Recipe: Identifiable, Hashable {
    let id = UUID()
    let name: String
}

struct RecipesView: View {
    let recipes = [Recipe(name: "Pfannkuchen"), Recipe(name: "Tomatensuppe"), Recipe(name: "Risotto")]
    @State private var path: [Recipe] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(recipes) { recipe in
                NavigationLink(recipe.name, value: recipe)
            }
            .navigationTitle("Rezepte")
            .navigationDestination(for: Recipe.self) { recipe in
                Text(recipe.name)
                    .font(.largeTitle)
            }
            .toolbar {
                Button("Überrasch mich") {
                    if let random = recipes.randomElement() {
                        path.append(random)
                    }
                }
            }
        }
    }
}

Das Wichtige:

  • @State private var path: [Recipe] = [] ist der Navigationszustand. Ein leeres Array heißt: Nur die Startseite ist zu sehen.
  • NavigationStack(path: $path) verbindet den Stack mit diesem Zustand, in beide Richtungen.
  • Tippt der Nutzer auf einen NavigationLink(value:), hängt SwiftUI den Wert selbst an path an.
  • Tippt er auf „Zurück“, entfernt SwiftUI das letzte Element selbst.
  • Und du kannst path jederzeit ändern: path.append(random) öffnet eine Seite.

Das Prinzip

Die Navigation ist einfach ein Array. Was im Array steht, ist zu sehen. Das ist dieselbe Idee wie überall in SwiftUI: Der Zustand bestimmt, was angezeigt wird (Lektion 2.9).

Was du alles mit dem Pfad machen kannst

Weil der Pfad ein ganz normales Array ist, kannst du alles damit machen, was du aus Lektion 1.6 kennst:

Swift
path.append(recipe)          // eine Seite öffnen
path.removeLast()            // eine Seite zurück
path.removeLast(2)           // zwei Seiten zurück
path.removeAll()             // ganz zurück zur Startseite
path = [a, b, c]             // direkt drei Ebenen tief springen

Besonders path.removeAll() ist praktisch: Nach einem mehrstufigen Ablauf führt ein einziger Befehl zurück zum Anfang.

Verschiedene Typen: NavigationPath

Ein [Recipe]-Pfad kann nur Rezepte enthalten. Was, wenn deine App von einem Rezept zu einer Zutat und von dort zu einer Kategorie navigiert, alles in einem Stack?

Dafür gibt es NavigationPath, einen Pfad, der beliebige Hashable-Werte aufnehmen kann:

Swift
struct Ingredient: Hashable {
    let name: String
}

struct CookbookView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Pfannkuchen", value: Recipe(name: "Pfannkuchen"))
                NavigationLink("Mehl", value: Ingredient(name: "Mehl"))
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetail(recipe: recipe)
            }
            .navigationDestination(for: Ingredient.self) { ingredient in
                Text(ingredient.name)
            }
        }
    }
}

NavigationPath funktioniert ähnlich wie ein Array:

Swift
path.append(Recipe(name: "Risotto"))
path.append(Ingredient(name: "Reis"))
path.removeLast()
path.count
path.isEmpty
path = NavigationPath()      // ganz zurück

Es gibt aber einen Unterschied: Du kannst die Elemente eines NavigationPath nicht auslesen. Du weißt, wie viele es sind, aber nicht, welche. SwiftUI hat ihre Typen „vergessen“, genau so, wie any Typen versteckt (Lektion 1.18).

Array oder NavigationPath?

  • Array wie [Recipe]: wenn alle Seiten im Stack vom selben Typ sind. Du kannst den Inhalt auslesen, prüfen und vergleichen. Erste Wahl, wenn es passt.
  • NavigationPath: wenn mehrere Typen in einem Stack vorkommen.

Eine dritte Möglichkeit ist oft die eleganteste: ein Enum für alle Ziele, und dann wieder ein Array. Das zeigt der nächste Abschnitt.

Ziele als Enum

Statt vieler Typen in einem NavigationPath beschreibst du alle möglichen Ziele in einem Enum:

Swift
enum Route: Hashable {
    case recipe(Recipe)
    case ingredient(Ingredient)
    case settings
}

Dann ist der Pfad wieder ein ganz normales, lesbares Array:

Swift
@State private var path: [Route] = []

NavigationStack(path: $path) {
    List {
        NavigationLink("Pfannkuchen", value: Route.recipe(Recipe(name: "Pfannkuchen")))
        NavigationLink("Einstellungen", value: Route.settings)
    }
    .navigationDestination(for: Route.self) { route in
        switch route {
        case .recipe(let recipe):
            RecipeDetail(recipe: recipe)
        case .ingredient(let ingredient):
            Text(ingredient.name)
        case .settings:
            SettingsView()
        }
    }
}

Vorteile:

  • Ein .navigationDestination für alle Ziele.
  • Der switch erzwingt, dass jedes Ziel behandelt wird (Lektion 1.14).
  • Der Pfad bleibt lesbar: if path.last == .settings { ... }.

Ein Router für die ganze App

Bisher liegt der Pfad als @State in einer View. Was, wenn eine tief verschachtelte View, etwa ein „Fertig“-Button auf der dritten Ebene, zurück zum Anfang springen soll? Den Pfad über drei Ebenen als @Binding durchzureichen, ist mühsam.

Die Lösung kennst du aus Lektion 3.3: ein @Observable-Objekt in der Environment.

Swift
@Observable
class Router {
    var path: [Route] = []

    func open(_ route: Route) {
        path.append(route)
    }

    func back() {
        if !path.isEmpty { path.removeLast() }
    }

    func backToRoot() {
        path.removeAll()
    }
}

Die Startseite erzeugt den Router und hängt ihn an:

Swift
struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
        .environment(router)
    }

    @ViewBuilder
    func destination(for route: Route) -> some View {
        switch route {
        case .recipe(let recipe): RecipeDetail(recipe: recipe)
        case .ingredient(let ingredient): Text(ingredient.name)
        case .settings: SettingsView()
        }
    }
}

Und jede View darunter, egal wie tief, holt ihn sich:

Swift
struct OrderConfirmation: View {
    @Environment(Router.self) private var router

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 64))
                .foregroundStyle(.green)
            Text("Bestellung abgeschickt!")
            Button("Zurück zum Start") {
                router.backToRoot()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

Beachte $router.path: Weil router als @State gehalten wird, kannst du mit $ ein Binding auf seine Eigenschaft path erzeugen (Lektion 3.2).

@ViewBuilder für Funktionen

Die Funktion destination(for:) gibt je nach Fall eine andere View zurück. Damit das mit some View funktioniert, braucht sie @ViewBuilder, genau wie body es automatisch hat. So kannst du switch und if darin wie in body verwenden.

Nach dem Speichern zur neuen Seite

Ein häufiger Fall: Ein Sheet legt einen neuen Eintrag an. Nach dem Speichern soll das Sheet schließen und die Detailseite des neuen Eintrags erscheinen.

Swift
struct RecipeListView: View {
    @Environment(Router.self) private var router
    @State private var showingNew = false

    var body: some View {
        List { ... }
            .toolbar {
                Button("Neu", systemImage: "plus") { showingNew = true }
            }
            .sheet(isPresented: $showingNew) {
                NewRecipeForm { newRecipe in
                    router.open(.recipe(newRecipe))
                }
            }
    }
}

Das Formular ruft nach dem Speichern eine Closure auf (Lektion 1.11) und schließt sich mit dismiss(). Die Liste öffnet daraufhin die Detailseite. Das Sheet fährt herunter, und gleichzeitig schiebt sich die neue Seite herein.

Typische Fehler

Swift
NavigationLink("Pfannkuchen", value: recipe)
// aber kein .navigationDestination(for: Recipe.self)

Der Link ist ausgegraut, und in der Konsole steht eine Warnung, dass kein passendes Ziel gefunden wurde. Jeder Typ im Pfad braucht ein .navigationDestination(for:).

Setz .navigationDestination an eine View innerhalb des NavigationStack, nicht an den Stack selbst und nicht in eine List-Zeile. Am besten an die Wurzel-View des Stacks.

Typen sind nicht Hashable

Text
Instance method 'navigationDestination(for:destination:)' requires that 'Recipe' conform to 'Hashable'

Alles, was in einen Pfad soll, muss Hashable sein. Meist genügt : Hashable hinter dem Typ.

Pfad verändert sich nicht

Du hast NavigationStack(path: path) geschrieben statt NavigationStack(path: $path). Ohne $ gibt es keine Verbindung in beide Richtungen.

FAQ

Anfänger

Was bedeutet das überhaupt?

Brauche ich überhaupt einen Pfad? Nein. Solange die Navigation nur durch Tippen auf Links passiert, reicht ein NavigationStack ohne Pfad. Den Pfad brauchst du erst, wenn dein Code navigieren soll.

Kann ich NavigationLink(destination:) weiterhin benutzen? Ja, für einfache Fälle. Diese Links tauchen aber nicht im Pfad auf. Wenn du einen Pfad verwendest, nimm NavigationLink(value:), damit alles an einer Stelle steht.

Fortgeschritten

Warum verhält es sich so?

Kann ich den Navigationszustand speichern, damit die App nach dem Neustart an derselben Stelle ist? Ja. Ist dein Route-Enum Codable, kannst du das Array mit JSONEncoder speichern, etwa in @SceneStorage (Lektion 3.13). Für NavigationPath gibt es path.codable, das eine speicherbare Darstellung liefert, wenn alle enthaltenen Typen Codable sind.

Wie navigiere ich aus einem View-Model heraus? Gib dem View-Model den Router mit, etwa im Initializer, oder lass das View-Model ein Ergebnis zurückgeben, auf das die View mit Navigation reagiert. Letzteres hält das View-Model unabhängig von der Navigation und leichter testbar.

Profi

Was passiert unter der Haube?

Wie funktioniert NavigationPath intern? Es speichert die Werte typgelöscht als any Hashable zusammen mit Typinformationen. Beim Anzeigen sucht SwiftUI für jeden Wert das .navigationDestination mit dem passenden Typ. Deshalb kannst du nichts auslesen: Du müsstest wissen, welcher Typ an welcher Stelle steht.

Was ist mit NavigationSplitView? Auf dem iPad und Mac zeigt NavigationSplitView Spalten nebeneinander, etwa Liste und Detail. Die Auswahl in der Seitenleiste ist dort meist ein einfacher @State mit dem gewählten Element, und in der Detailspalte kann ein eigener NavigationStack mit Pfad liegen. Auf dem iPhone fällt sie automatisch zu einem einzelnen Stack zusammen.

Übungen

Anfänger

Übung 1: Zahlen-Treppe

Baue einen NavigationStack mit einem Pfad [Int]. Jede Seite zeigt groß ihre Zahl und einen Button „Weiter“, der die nächste Zahl anhängt, und einen Button „Zum Anfang“, der alles entfernt.

Lösung Übung 1Erst selbst probieren!
Swift
struct StairsView: View {
    @State private var path: [Int] = []

    var body: some View {
        NavigationStack(path: $path) {
            Button("Los geht's") { path.append(1) }
                .navigationTitle("Start")
                .navigationDestination(for: Int.self) { number in
                    VStack(spacing: 20) {
                        Text("\(number)").font(.system(size: 80, weight: .bold))
                        Button("Weiter") { path.append(number + 1) }
                        Button("Zum Anfang") { path.removeAll() }
                    }
                    .navigationTitle("Stufe \(number)")
                }
        }
    }
}
Fortgeschritten

Übung 2: Route-Enum

Bau die Rezept-App aus dieser Lektion auf ein Route-Enum mit drei Fällen um. Füge einen Toolbar-Button hinzu, der direkt zu „Pfannkuchen → Mehl“ springt, also zwei Ebenen tief auf einmal.

Profi

Übung 3: Router mit Bestellablauf

Baue einen Ablauf über drei Seiten (Warenkorb → Adresse → Bestätigung) mit einem Router in der Environment. Die Bestätigungsseite hat einen Button „Zurück zum Start“. Speichere den Pfad zusätzlich mit @SceneStorage, sodass die App nach einem Neustart an derselben Stelle ist.

Zusammenfassung

  • NavigationStack(path: $path) macht die Navigation zu einem Zustand, den dein Code lesen und ändern kann.
  • Ein Array als Pfad: append öffnet, removeLast geht zurück, removeAll springt zum Anfang.
  • NavigationPath nimmt verschiedene Hashable-Typen auf, ist aber nicht auslesbar.
  • Ein Route-Enum bündelt alle Ziele in einem lesbaren Array mit einem einzigen .navigationDestination.
  • Ein @Observable-Router in der Environment macht Navigation aus jeder Tiefe möglich.

Spickzettel

Swift
enum Route: Hashable {
    case recipe(Recipe)
    case settings
}

@Observable class Router {
    var path: [Route] = []
    func open(_ r: Route) { path.append(r) }
    func backToRoot() { path.removeAll() }
}

@State private var router = Router()

NavigationStack(path: $router.path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            switch route {
            case .recipe(let r): RecipeDetail(recipe: r)
            case .settings: SettingsView()
            }
        }
}
.environment(router)

@Environment(Router.self) private var router
NavigationLink("Öffnen", value: Route.settings)

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.