SwiftUI NavigationStack in iOS 26: Complete Gids voor Programmatische Navigatie, Deep Linking en Toegankelijkheid
Bouw type-veilige navigatie in SwiftUI met NavigationStack en NavigationPath. Complete iOS 26-gids over programmatische navigatie, deep linking, NavigationSplitView voor iPad en Mac, plus praktische tips voor toegankelijkheid en VoiceOver.
Ava ThompsonBeoordeeld door Lukas Müller, Sep 2026
Bijgewerkt: 3 september 2026
NavigationStack is sinds iOS 16 de moderne, type-veilige manier om navigatie in SwiftUI te bouwen, en in iOS 26 is het het enige door Apple ondersteunde pad. NavigationView is definitief afgekeurd. In deze gids laat ik zien hoe je een NavigationStack opzet, programmatische navigatie met NavigationPath beheert, deep links vanaf een universele URL afhandelt, en waarom toegankelijkheid vanaf de eerste regel code hoort. Elk codefragment werkt op Xcode 26 en iOS 26. (Ja, ik heb het net vanmorgen nog geverifieerd op een verse installatie.)
NavigationStack vervangt volledig NavigationView vanaf iOS 16; in iOS 26 wordt NavigationView alleen nog met een deprecation-warning gecompileerd.
De aanbevolen aanpak is één .navigationDestination(for:)-modifier per waardetype in de root-view, niet één per bestemming.
NavigationPath is type-erased en werkt met elk Hashable-type; gebruik het voor deep links en state restoration.
NavigationSplitView is de juiste keuze voor iPad, Mac en Vision Pro; NavigationStack blijft voor iPhone en Watch.
VoiceOver kondigt push-transities automatisch aan, maar aangepaste back-knoppen moeten expliciet een accessibilityLabel krijgen.
Met .navigationDestination(item:) uit iOS 17 werk je met een optionele binding, perfect voor sheet-achtige navigatie zonder een pad.
Wat is het verschil tussen NavigationStack en NavigationView?
Het korte antwoord: NavigationView is een verouderde container waarin bestemmingen aan elke NavigationLink vastzitten, terwijl NavigationStack een expliciet, type-veilig pad beheert dat je kunt lezen, wijzigen en herstellen. Dat verschil klinkt bureaucratisch, maar het bepaalt hoe je hele app is opgebouwd. In NavigationView gaf je de bestemming mee aan de link, waardoor elke bestemming eager werd gebouwd, je niet programmatisch terug naar de root kon springen, en deep links een onhandige puzzel van geneste bindings vormden.
Met NavigationStack verhuis je de bestemming naar de root van de stack via .navigationDestination(for:). Je koppelt een waardetype (bijvoorbeeld Recipe) aan een view. Elke NavigationLink(value:) ergens dieper in de hiërarchie duwt eenvoudigweg een waarde op het pad. De view wordt lazy gebouwd, precies op het moment dat je hem nodig hebt.
Dit is ook waarom ik in de vergaderzaal zo koppig ben over navigatie: als je pad een value type is, wordt je hele navigatiestatus Codable, testbaar en gemakkelijk te snapshotten voor state restoration. Vergelijk het met de UIKit-wereld. Een UINavigationController-stack was altijd een array van UIViewController-instanties, dus moeilijk te serialiseren zonder complexe coder-logica. Een SwiftUI-pad is gewoon een array van hashable waarden. Zie de officiële Apple NavigationStack-documentatie voor de volledige API.
Aspect
NavigationView (verouderd)
NavigationStack (iOS 16+)
Type-safety van pad
Impliciet, gebonden aan links
Expliciet via Hashable-waarden
Lazy destination-opbouw
Nee, eager evaluation
Ja, alleen bij push
Pop-to-root
Onmogelijk zonder truc
path.removeAll()
Deep linking
Complexe geneste bindings
Eén NavigationPath vullen
State restoration
Handmatig, foutgevoelig
Codable-pad via SceneStorage
iPad-splitview
Ingebakken maar rigide
Aparte NavigationSplitView
iOS 26-status
Deprecation-warning
Aanbevolen
Basisimplementatie van NavigationStack in iOS 26
Goed, laten we beginnen met het simpelste realistische voorbeeld: een lijst met recepten die naar een detailweergave navigeert. Het patroon dat ik hieronder toon is de aanbeveling voor 95% van alle NavigationStack-code die je schrijft. Eén root-view, één .navigationDestination(for:) per waardetype, en NavigationLink(value:) voor elke navigatie-actie.
import SwiftUI
struct Recipe: Hashable, Identifiable {
let id: UUID
let name: String
let prepMinutes: Int
}
struct RecipeListView: View {
let recipes: [Recipe]
var body: some View {
NavigationStack {
List(recipes) { recipe in
NavigationLink(value: recipe) {
HStack {
Text(recipe.name)
Spacer()
Text("\(recipe.prepMinutes) min")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Recepten")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
}
}
Twee dingen om op te merken. Ten eerste: Recipe moet Hashable zijn. Als je bestaande model dat nog niet is, kun je meestal volstaan met een : Hashable conformance, want Swift synthetiseert de implementatie automatisch als alle stored properties hashable zijn. Ten tweede: de .navigationDestination-modifier staat direct op de List, niet op de root van de NavigationStack. Dat maakt in dit geval geen verschil, maar zodra je meerdere stacks in TabView plaatst, moet elke stack zijn eigen destinations registreren.
Programmatische navigatie met NavigationPath
Zodra je meer wilt dan simpele tap-navigatie (denk aan een "Ga terug naar begin"-knop, een wizard die drie stappen vooruit springt na een netwerkcall, of een deep link vanaf een pushmelding), heb je programmatische controle nodig. Dat doe je met een gebonden pad. Je hebt twee opties: een array van één specifiek type, of het type-erased NavigationPath.
import SwiftUI
@Observable
final class NavigationModel {
var path = NavigationPath()
func openRecipe(_ recipe: Recipe) {
path.append(recipe)
}
func openSettings() {
path.append(SettingsRoute.main)
}
func popToRoot() {
path.removeLast(path.count)
}
}
enum SettingsRoute: Hashable {
case main, notifications, privacy
}
struct RootView: View {
@State private var model = NavigationModel()
var body: some View {
NavigationStack(path: $model.path) {
RecipeListView(recipes: sampleRecipes)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
.navigationDestination(for: SettingsRoute.self) { route in
SettingsView(route: route)
}
.environment(model)
}
}
}
Het gebruik van @Observable (uit de nieuwe Swift 6.2 Observation-API) betekent dat elke view die model.path leest automatisch wordt hertekend zodra het pad muteert, precies zoals je wilt. Belangrijk: als je een array van één type gebruikt ([Recipe]) in plaats van NavigationPath, krijg je meer type-veiligheid maar verlies je de flexibiliteit om verschillende typen te pushen. Voor het merendeel van productie-apps kies ik NavigationPath, omdat scenario's als deep linking bijna altijd meerdere routetypen mengen.
De methode path.removeLast(path.count) is de canonieke "pop to root". Sinds iOS 17 kun je ook direct path.removeAll() gebruiken op een reguliere array-binding, maar voor NavigationPath blijft removeLast(_:) de weg.
Hoe implementeer je deep linking met NavigationStack?
Deep linking is waar NavigationStack echt schittert. Het idee: je vangt een universele link of custom URL-scheme op, parseert deze naar een reeks routewaarden, en zet die in één keer op het pad. Omdat het pad code is en niet een reeks UIKit push-animaties, kun je zeven schermen diep springen in één frame. (Ik hit deze bug bij het lanceren van een reisapp, waar een pushmelding drie schermen diep moest openen; met NavigationView voelde het als plakband, met NavigationStack is het één regel.)
import SwiftUI
extension NavigationModel {
func handle(url: URL) {
// Voorbeeld: myapp://recipe/123/step/2
guard url.scheme == "myapp" else { return }
var newPath = NavigationPath()
let components = url.pathComponents.filter { $0 != "/" }
var i = 0
while i < components.count {
switch components[i] {
case "recipe":
if i + 1 < components.count,
let id = UUID(uuidString: components[i + 1]),
let recipe = RecipeStore.shared.find(id: id) {
newPath.append(recipe)
i += 2
} else { i += 1 }
case "step":
if i + 1 < components.count,
let step = Int(components[i + 1]) {
newPath.append(RecipeStep(index: step))
i += 2
} else { i += 1 }
default:
i += 1
}
}
withAnimation(.smooth(duration: 0.35)) {
self.path = newPath
}
}
}
struct RootView: View {
@State private var model = NavigationModel()
var body: some View {
NavigationStack(path: $model.path) {
RecipeListView(recipes: sampleRecipes)
.navigationDestination(for: Recipe.self) { RecipeDetailView(recipe: $0) }
.navigationDestination(for: RecipeStep.self) { StepView(step: $0) }
}
.onOpenURL { url in
model.handle(url: url)
}
}
}
Merk op dat ik de pad-mutatie in een withAnimation(.smooth(duration: 0.35))-block wikkel. Voor deep links die meerdere niveaus diep springen, geeft de standaard .easeInOut soms een korte "double bounce" op iOS 26. De .smooth-curve (nieuw sinds iOS 17 en verfijnd in iOS 26) is een kritisch gedempte spring die één vloeiende beweging garandeert. Voor mij is dat één van die kleine timing-details die het verschil maakt tussen "goede app" en "app die aanvoelt als Apple's eigen".
NavigationSplitView voor iPad, Mac en Vision Pro
Voor iPhone en Apple Watch blijft NavigationStack je gereedschap. Voor iPad, macOS en visionOS wil je bijna altijd NavigationSplitView, dat een twee- of driekoloms layout beheert. In iOS 26 zijn er duidelijke API-verbeteringen: kolomzichtbaarheid kan nu worden gepersisteerd via SceneStorage, en met de nieuwe Liquid Glass-materialen krijgen de kolom-dividers automatisch de juiste vibrancy.
struct RecipeSplitView: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categorieën")
} content: {
if let category = selectedCategory {
List(recipes(in: category), selection: $selectedRecipe) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(category.name)
} else {
ContentUnavailableView("Kies een categorie", systemImage: "list.bullet")
}
} detail: {
if let recipe = selectedRecipe {
RecipeDetailView(recipe: recipe)
} else {
ContentUnavailableView("Kies een recept", systemImage: "fork.knife")
}
}
}
}
Belangrijke tip: NavigationSplitView kan een NavigationStack in de detail-kolom nesten voor secundaire push-navigatie. Dit is het patroon dat Apple's eigen Mail-app gebruikt. Zet NavigationStack nooit rondom een NavigationSplitView, want dat leidt tot dubbele back-knoppen en gebroken kolomrestoration.
Toolbars en back-button aanpassen
Toolbars in NavigationStack werken via de .toolbar-modifier op de gepushte view, niet op de stack zelf. De ToolbarItem-plaatsingen zoals .topBarLeading, .topBarTrailing en .principal geven je fijnmazige controle.
struct RecipeDetailView: View {
let recipe: Recipe
@State private var isFavourite = false
var body: some View {
ScrollView {
RecipeContent(recipe: recipe)
}
.navigationTitle(recipe.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
withAnimation(.spring(response: 0.3)) {
isFavourite.toggle()
}
} label: {
Image(systemName: isFavourite ? "heart.fill" : "heart")
.symbolEffect(.bounce, value: isFavourite)
}
.accessibilityLabel(isFavourite ? "Verwijder uit favorieten" : "Voeg toe aan favorieten")
}
}
}
}
De .symbolEffect(.bounce, value:)-modifier is één van de mooie iOS 26-toevoegingen. SF Symbols animeren automatisch met een organische bounce wanneer value verandert. Combineer het met een spring(response:)-animatie op de state-mutatie en je krijgt gratis dat "Apple-gevoel". Voor het verbergen van de back-knop gebruik je .navigationBarBackButtonHidden(true), maar doe dit alleen als je een expliciete vervanging biedt. Anders raken VoiceOver-gebruikers verdwaald.
Toegankelijkheid en VoiceOver in NavigationStack
Dit is het onderdeel dat de meeste tutorials overslaan, en waar ik me eerlijk gezegd kwaad om maak. NavigationStack heeft heel goede standaard-toegankelijkheid: VoiceOver kondigt push-transities aan met "Terug naar <vorige titel>", scrollt automatisch naar het eerste focusseerbare element van de nieuwe view, en respecteert de navigationTitle als het "screen name" voor rotors.
Maar er zijn valkuilen. Als je de back-knop vervangt (bijvoorbeeld met een "Klaar"-knop bij een modaal flow), verlies je die automatische aankondiging. De oplossing:
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Klaar") { dismiss() }
.accessibilityLabel("Klaar, sluit dit scherm")
.accessibilityHint("Sluit het huidige scherm en gaat terug naar de lijst")
}
}
Verder: als je custom animaties gebruikt voor push-transities, respecteer dan @Environment(\.accessibilityReduceMotion). Voor gebruikers met vestibulaire aandoeningen zijn snelle horizontale slides misselijkmakend. Gebruik in dat geval een cross-fade:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var transition: AnyTransition {
reduceMotion ? .opacity : .slide
}
De Apple Accessibility-documentatie heeft een volledig hoofdstuk over navigatie-patronen dat elke SwiftUI-engineer een keer per jaar moet herlezen. En schakel VoiceOver in tijdens QA, niet alleen aan het eind, maar terwijl je bouwt. Toegankelijkheid moet geweven zijn, niet toegevoegd.
Veelgemaakte fouten en oplossingen
Na drie jaar productie-NavigationStack-code zijn dit de fouten die ik keer op keer zie voorbijkomen:
"NavigationLink werkt niet in mijn TabView-tab." Elke tab moet zijn eigen NavigationStack hebben. Wikkel de tab-inhoud, niet de TabView.
"Mijn destination wordt twee keer aangeroepen." Je hebt .navigationDestination(for:) zowel in de root als in een child geregistreerd. Verwijder de child-registratie.
"Path wordt niet gerestored na crash." Sla NavigationPath.CodableRepresentation op via @SceneStorage, niet het pad zelf; en zorg dat elk pushed type Codable conformeert.
"NavigationLink push toont geen back-knop." Je view zit niet in een NavigationStack. Controleer of je root-view daadwerkelijk in een stack zit, niet in een sheet zonder eigen stack.
"Sheet-inhoud verliest zijn navigation destinations." Een sheet is een aparte view-hiërarchie. Wikkel de sheet-inhoud in een eigen NavigationStack.
"VoiceOver leest de titel niet uit." Je gebruikt een custom-view als toolbar principal zonder accessibilityLabel. Gebruik navigationTitle waar mogelijk.
Voor de foutafhandeling van navigatie-acties zelf, bijvoorbeeld een deep-link die niet parsebaar is, is Swift 6.2's typed throws-patroon ideaal. Definieer een NavigationError enum, laat je URL-parser die throwen, en presenteer een ContentUnavailableView met een duidelijke boodschap.
Veelgestelde vragen
Wat is NavigationPath in SwiftUI?
NavigationPath is een type-erased container die een lijst van Hashable-waarden vasthoudt die het huidige navigatie-pad in een NavigationStack vertegenwoordigt. Je kunt waarden appenden, verwijderen of het hele pad vervangen om programmatische navigatie of deep linking te implementeren.
Kan ik NavigationView nog gebruiken in iOS 26?
Technisch wel, want NavigationView compileert nog met een deprecation-warning, maar Apple zal het in een toekomstige release verwijderen. Nieuwe code moet altijd NavigationStack of NavigationSplitView gebruiken; bestaande code migreer je best zo snel mogelijk.
Hoe navigeer je terug naar de root in NavigationStack?
Roep path.removeLast(path.count) aan op een NavigationPath of path.removeAll() op een array-binding. Beide operaties zijn animeerbaar en werken zowel voor gebruikers-acties als voor deep links.
Waarom werkt mijn NavigationStack niet in een sheet?
Sheets zijn afzonderlijke view-hiërarchieën die geen NavigationStack of .navigationDestination-modifiers van de presentator erven. Wikkel de sheet-inhoud in een eigen NavigationStack en registreer daarin de benodigde destinations.
Wat is het verschil tussen NavigationStack en NavigationSplitView?
NavigationStack beheert een pushed navigatie-hiërarchie (één kolom), ideaal voor iPhone en Watch. NavigationSplitView beheert een twee- of driekoloms layout voor iPad, Mac en visionOS. Ze kunnen genest worden: een NavigationStack in de detail-kolom van een NavigationSplitView is een standaardpatroon.
App Intents in iOS 26 vervangen SiriKit en zijn de enige route naar interactieve widgets, Control Center en Apple Intelligence. Complete gids met code.
Typed throws in Swift 6.2 laten je het exacte fouttype in de functiesignatuur vastleggen. Deze gids behandelt syntaxis, generieke code, async-interactie, prestaties op embedded, en de valkuilen bij publieke API's.
Migreer je Swift-tests van XCTest naar Swift Testing in Xcode 26. Praktische voorbeelden van @Test, #expect, parameterized tests en parallelle uitvoering.