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.
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 anpathan. - Tippt er auf „Zurück“, entfernt SwiftUI das letzte Element selbst.
- Und du kannst
pathjederzeit ä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:
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 springenBesonders 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:
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:
path.append(Recipe(name: "Risotto"))
path.append(Ingredient(name: "Reis"))
path.removeLast()
path.count
path.isEmpty
path = NavigationPath() // ganz zurückEs 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:
enum Route: Hashable {
case recipe(Recipe)
case ingredient(Ingredient)
case settings
}Dann ist der Pfad wieder ein ganz normales, lesbares Array:
@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
.navigationDestinationfür alle Ziele. - Der
switcherzwingt, 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.
@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:
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:
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.
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
NavigationLink ohne passendes Ziel
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:).
.navigationDestination an der falschen Stelle
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
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
Ü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!
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)")
}
}
}
}Ü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.
Ü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,removeLastgeht zurück,removeAllspringt zum Anfang. NavigationPathnimmt verschiedeneHashable-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
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)