SwiftUI NavigationStack: Vodič za modernu navigaciju u iOS 26
Kompletni vodič za SwiftUI NavigationStack u iOS 26: value-based NavigationLink, NavigationPath, pop-to-root, deep linking iz URL-a, TabView integracija i router uzorak s @Observable.
SwiftUI NavigationStack je moderni container za push-navigaciju uveden u iOS 16 koji zamjenjuje zastarjeli NavigationView i omogućuje potpuno programsku, tipski sigurnu i deep-link-friendly navigaciju kroz stog pogleda. U iOS 26 dobio je novi Liquid Glass izgled navigacijske trake, ali API je stabilan: jednom kad naučiš raditi s NavigationPath, navigationDestination(for:) i router uzorkom, imaš temelj za bilo koju iOS aplikaciju od master-detail preglednika do TabView-based klijenata s dubokim linkovima. Ovaj vodič je moje kompletno objašnjenje s primjerima koji se pokreću u Xcode 26 (i s bilješkama iz nekoliko produkcijskih projekata koje sam vodila).
NavigationView je službeno deprecated od iOS 16, pa svi novi projekti trebaju koristiti NavigationStack ili NavigationSplitView.
NavigationStack koristi vrijednosno-orijentirane NavigationLink(value:) pozive u kombinaciji s .navigationDestination(for:), pa nema više destination closure-a u svakoj ćeliji.
NavigationPath je tipski izbrisani stog koji možeš držati u @Observable routeru; postaviš ga na prazan da napraviš pop-to-root u jednoj liniji.
Deep linking radi tako da onOpenURL parsira URL u tvoj Route enum i doda ga na NavigationPath. Bitno: mount destination prije toga, inače se push tiho ignorira.
U iOS 26 svaki tab u TabView mora imati vlastiti NavigationStack; nemoj gniježđati stogove.
Pristupačnost: SwiftUI automatski najavljuje push tranzicije preko VoiceOvera, ali destination pogled treba jasan navigationTitle jer to postaje najavljena oznaka ekrana.
Što je NavigationStack u SwiftUI
NavigationStack je SwiftUI container koji drži stog pogleda i prikazuje ih u push/pop hijerarhiji s ugrađenom navigacijskom trakom. Uveden je u iOS 16 (WWDC 2022) i zamjenjuje stariji NavigationView, koji je bio deklarativno rigidan: svaki NavigationLink je morao poznavati svoj destination u trenutku deklaracije, što je onemogućavalo istinski programsku navigaciju.
Ključni pomak u NavigationStack je razdvajanje što gurnuti od gdje ga renderirati. NavigationLink sada prima samo vrijednost (bilo koji Hashable tip), a odgovarajući .navigationDestination(for:) modifikator negdje iznad u hijerarhiji odlučuje koji pogled renderirati. To je isti mentalni model koji koristi web router (URL kao vrijednost, komponenta kao destinacija), samo lokalno unutar SwiftUI stabla.
Prema službenoj Apple dokumentaciji, NavigationStack podržava i inicijalizator s bindanjem na NavigationPath, što otvara vrata programske manipulacije: push, pop, pop-to-root, čak i rekonstrukcija stoga iz spremljenog stanja pri pokretanju aplikacije. To ga čini prvim navigacijskim API-jem u SwiftUI ekosustavu koji je istovremeno deklarativan i potpuno kontroliv iz koda.
NavigationStack vs NavigationView vs NavigationSplitView
Ako pišeš novi kod u 2026., odgovor je jednostavan: NavigationView je gotov, a između NavigationStack i NavigationSplitView biraš prema strukturi aplikacije. NavigationStack je za push-navigaciju u jednom stupcu (klasična iPhone forma), a NavigationSplitView je za master-detail iskustva koja se adaptivno šire na iPadu i Macu (dva ili tri stupca). Uobičajen produkcijski uzorak je NavigationSplitView na vanjskoj razini, s NavigationStack unutar detail stupca.
Značajka
NavigationView (legacy)
NavigationStack
NavigationSplitView
Uveden
iOS 13, deprecated iOS 16
iOS 16+
iOS 16+
Broj stupaca
1
1
2 ili 3, adaptivno
Programska navigacija
Vrlo ograničena
Puna kontrola preko NavigationPath
Preko selection binding-a i unutrašnjeg NavigationStack
Deep linking
Nespretno, često kroz @State flagove
Prvorazredna podrška
Prvorazredna podrška
Idealna platforma
Nijedna (migriraj)
iPhone, watchOS
iPad, Mac, iPhone landscape
State restoration
Ručno
Codable NavigationPath + SceneStorage
Kombinacija selekcije i putanje
iOS 26 Liquid Glass
Ne
Automatski
Automatski
Za watchOS i CarPlay projekte gdje je ekran uvijek jedan stupac, NavigationStack je jedini razuman izbor. Za sve što će se pokretati na iPadu i Macu, prisili se od prvog dana koristiti NavigationSplitView. Refactor kasnije je bolan jer moraš iznova promisliti gdje živi state selektiranog itema (znam to iz vlastite kože).
Osnovna sintaksa i value-based NavigationLink
Osnovna anatomija NavigationStack ima tri dijela: container, jedan ili više NavigationLink(value:) poziva, i po jedan .navigationDestination(for:) modifikator za svaki tip vrijednosti koji možeš gurnuti. Kad korisnik tapne link, SwiftUI usporedi tip vrijednosti s registriranim destinacijama i renderira odgovarajući pogled.
import SwiftUI
struct Recipe: Hashable, Identifiable {
let id: UUID
let name: String
let prepTime: Int
}
struct RecipeListView: View {
let recipes: [Recipe]
var body: some View {
NavigationStack {
List(recipes) { recipe in
NavigationLink(value: recipe) {
VStack(alignment: .leading) {
Text(recipe.name).font(.headline)
Text("\(recipe.prepTime) min").foregroundStyle(.secondary)
}
}
}
.navigationTitle("Recepti")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
}
}
Uoči da NavigationLink više ne prima destination closure. Umjesto toga, tvoja odgovornost je registrirati tip (Recipe.self) i dati SwiftUI-ju jedno pravilo kako ga renderirati. To znači da isti pogled može biti guran iz deset različitih mjesta bez dupliciranja koda. Ako sutra dodaš rutu iz search rezultata ili iz push notifikacije, ne moraš dirati destination logiku.
Programska navigacija s NavigationPath
Za bilo što složenije od "tapni ćeliju, otvori detalj", trebaš držati stog kao vlastito stanje. NavigationPath je tipski izbrisan spremnik koji može držati vrijednosti različitih Hashable tipova. Bindaš ga na NavigationStack(path:) i tada svaka mutacija (append, removeLast, dodjela nove instance) animirano ažurira stog.
@Observable
final class Router {
var path = NavigationPath()
func showRecipe(_ recipe: Recipe) {
path.append(recipe)
}
func popToRoot() {
path = NavigationPath()
}
}
struct RootView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
RecipeListView()
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
.environment(router)
}
}
Prednost tipski izbrisanog NavigationPath je što možeš miješati tipove; path.append(recipe) i path.append(ingredient) u istom stogu su legitimni ako imaš registriranu navigationDestination za oba. Nedostatak je što ne možeš pročitati konkretne elemente iz sredine stoga. Ako trebaš tu introspekciju (npr. za analitiku "korisnik je trenutno na screen X"), koristi umjesto toga typed array poput @State var path: [Route] = [], gdje je Route tvoj enum.
Kako proslijediti podatke između pogleda
Podaci se prosljeđuju kao sama vrijednost (NavigationLink(value: recipe) ili path.append(recipe)) i primaju u closure-u .navigationDestination(for:). Ovo je čistiji model od starog NavigationLink(destination: RecipeDetail(recipe: recipe)), jer destination nije eagerly instanciran čim se lista renderira, već tek kad korisnik zapravo naviguje.
Za veće modele, prosljeđuj identifikator (UUID) umjesto cijelog objekta, pa destination fetcha kompletni model iz repozitorija. To je ista strategija koju bi koristio i za persistenciju putanje, jer UUID je stabilan, sam Recipe struct nije. Ako koristiš SwiftData za perzistenciju, prosljeđuj PersistentIdentifier i u destination pogledu koristi @Query ili direktan fetch iz konteksta.
enum Route: Hashable {
case recipe(id: UUID)
case ingredient(id: UUID)
case settings
}
struct RecipeDetailView: View {
let recipeId: UUID
@Environment(RecipeRepository.self) private var repo
var body: some View {
if let recipe = repo.recipe(id: recipeId) {
RecipeContent(recipe: recipe)
} else {
ContentUnavailableView("Recept nije pronađen", systemImage: "questionmark.folder")
}
}
}
Kako se vratiti na root u NavigationStack
Pop-to-root u NavigationStack je jedna linija: postaviš path na praznu vrijednost. Ako koristiš NavigationPath, to je path = NavigationPath(). Ako koristiš typed array, to je path.removeAll(). Obje varijante animirano poništavaju stog i vraćaju korisnika na root pogled. Nema potrebe za NavigationLink hakovima s isActive bindingom kakve smo pisali u eri NavigationView.
Button("Vrati me na početak") {
withAnimation {
router.popToRoot()
}
}
.accessibilityHint("Zatvara sve otvorene ekrane i vraća se na glavni popis")
Za scenarij "pop back N ekrana", koristi path.removeLast(n) na typed array-ju. NavigationPath također ima removeLast(_:), ali samo za konsekutivan pop s vrha, bez random accessa. U mojem produkcijskom kodu radije držim [Route] upravo zbog te fleksibilnosti; type erasure u NavigationPath je koristan samo ako stvarno miješaš potpuno različite domene u istom stogu.
Deep linking iz URL-a s NavigationStack
Deep linking je gdje NavigationStack stvarno zablista. Recept je: primi URL u onOpenURL(perform:), parsiraj ga u svoju Route vrijednost, i appendaj na router path. Jedina začkoljica je da .navigationDestination(for:) modifikatori moraju biti mount-ani prije nego što napraviš append, inače SwiftUI ignorira push bez ikakve pogreške. (Ovaj sam bug lovila cijelo popodne prije nego što mi je pao žeton.)
@main
struct RecipeApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
RootView()
.environment(router)
.onOpenURL { url in
guard let route = Route(url: url) else { return }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
router.path.append(route)
}
}
}
}
}
extension Route {
init?(url: URL) {
guard url.scheme == "recepti" else { return nil }
switch url.host {
case "recipe":
guard let idString = url.pathComponents.dropFirst().first,
let id = UUID(uuidString: idString) else { return nil }
self = .recipe(id: id)
case "settings":
self = .settings
default: return nil
}
}
}
Kratki Task.sleep od 50ms izgleda smiješno, ali rješava race u kojem se scene inicijalizira istovremeno s URL eventom i destinacije još nisu registrirane. Elegantnije rješenje je držati "pending route" u routeru i procesirati ga u .onAppear root pogleda, kao što opisuje Majid Jabrayilov u svojem vodiču o NavigationStack deep linkingu.
Kombiniranje NavigationStack s TabView
U tab-based aplikacijama, pravilo je jednostavno: svaki tab dobiva vlastiti NavigationStack. Ne stavljaj TabView unutar NavigationStack, jer ćeš dobiti jedan zajednički stog koji se ne resetira između tabova i navigacijski naslov će se pomicati. Umjesto toga, TabView ide na vanjskoj razini, a svaki tab child ima svoj stack.
iOS ima dugogodišnji Human Interface Guidelines uzorak: tap na već aktivni tab treba popati stack tog taba na root. SwiftUI to ne radi automatski, ali lako implementiraš čitajući selection binding i uspoređujući s prethodnom vrijednošću, i kad su iste, resetiraj path tog taba. U mojoj praksi ovo je must-have jer korisnici to očekuju iz Apple aplikacija poput App Storea i Mail-a.
Router uzorak s @Observable
Router (ponekad zvan Coordinator) je poseban tip koji drži svu navigacijsku logiku izvan pogleda. Prije iOS 17 morali smo koristiti ObservableObject s @Published propertijima, ali od iOS 17 Observation framework s @Observable makrom je čišći put, s manje boilerplate koda i finije-granularnim rerenderima.
Prednost izdvojenog routera je testabilnost. Možeš unit-testirati router.handleDeepLink(URL(string: "recepti://recipe/UUID")!) bez ijednog ViewInspector-a. To je dio moje standardne SwiftUI arhitekture, i ako te zanima kako se poklapa s ostatkom, moj vodič o Swift Concurrency i akterima pokriva kako izolirati router pod @MainActor anotaciju za sigurnost od podatkovnih utrka.
Očuvanje stanja navigacije
NavigationPath je Codable ako su svi tipovi u njemu Codable. To znači da putanju možeš serializirati u JSON i spremiti u SceneStorage, pa kad korisnik ubije aplikaciju i vrati se, stack se rekonstruira točno tamo gdje je bio. Ovo je jedna od najzanemarenijih značajki modernog SwiftUI-ja, jer većina aplikacija ne implementira restoration i korisnik se svaki put vraća na root.
struct RootView: View {
@SceneStorage("nav.path") private var pathData: Data?
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
RecipeListView()
.navigationDestination(for: Route.self) { RouteView(route: $0) }
}
.task {
if let data = pathData,
let repr = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
path = NavigationPath(repr)
}
}
.onChange(of: path) { _, newPath in
pathData = newPath.codable.flatMap { try? JSONEncoder().encode($0) }
}
}
}
Za typed [Route] array, restoration je još jednostavniji. Route enum je već Codable (samo dodaj Codable conformance), i cijelu putanju enkodiraš direktno bez CodableRepresentation proxy tipa.
Pristupačnost i VoiceOver
SwiftUI NavigationStack daje ti puno pristupačnosti besplatno: VoiceOver najavljuje "Nazad" gumb, čita navigationTitle kao ime ekrana kad se push dogodi, i pravilno pomiče fokus na prvi interaktivni element novog pogleda. Ono što ti moraš dodati je jasan naslov na svakom destination pogledu i smisleni accessibilityHint na svaki NavigationLink čija svrha nije očita iz njegovog labela.
NavigationLink(value: recipe) {
RecipeRow(recipe: recipe)
}
.accessibilityHint("Otvara detalje recepta i popis sastojaka")
Za pop-to-root gumbove koristi .accessibilityLabel koji jasno kaže "Vrati se na početak" umjesto samo "Kuća" ikone. Kad korisnik napravi programski deep-link push, razmisli o UIAccessibility.post(notification: .screenChanged, argument: nil) kroz UIKit bridge da fokus skoči na novi ekran, jer SwiftUI to ne radi uvijek pouzdano za programske pushove. Testiraj s uključenim VoiceOverom (Settings → Accessibility → VoiceOver ili triple-click side gumba) na fizičkom uređaju. Simulator ga ima, ali gestama i fokus tranzicijama vjeruj samo na hardveru.
Uobičajene zamke i kako ih izbjeći
Za kraj, popis grešaka koje sam vidjela (i sama napravila) u produkcijskim SwiftUI projektima:
Zaboravljen .navigationDestination: push se tiho ignorira. Provjeri konzolu za "no matching navigationDestination" upozorenje.
Gniježđeni NavigationStack-ovi: stog unutar stoga rezultira dupliciranom navigacijskom trakom i neurednim animacijama. Koristi jedan stack po ekranu.
Auto-pop pri rebuild-u roditelja: ako parent view rebuild-a i @State path se resetira, stack se prazni. Premjesti router state u @Observable environment.
Deep link race: URL stiže prije nego što je destination mounted. Riješi kratkim delayem ili "pending route" queueom u routeru.
iOS 18 searchable bug: reset patha dok je search bar aktivan ponekad ostavi vizualnu tranziciju nedovršenom. Radi path.removeAll() unutar DispatchQueue.main.async bloka.
Stari NavigationLink(destination:): radi u NavigationStack zbog kompatibilnosti, ali zaobilazi .navigationDestination sustav i lomi deep linking. Migriraj sve na value-based inicijalizator.
Tab-switch mid-transition: brzo prebacivanje između tabova dok se push animira može ostaviti stack u nekonzistentnom stanju. Debounca gumbove ili disable-aj TabView tijekom aktivne tranzicije ako je kritično.
Prema Hacking with Swift forumu o NavigationView deprecationu, još uvijek postoji podosta legacy koda u aktivnim projektima. Dobra vijest je da je migracija uglavnom mehanička: zamijeni NavigationView s NavigationStack, prebaci sve NavigationLink(destination:) na value-based, dodaj .navigationDestination(for:). Za veće aplikacije ostavi si vikend.
Često postavljana pitanja
Je li NavigationView zastario u SwiftUI?
Da. NavigationView je službeno deprecated od iOS 16 i Apple aktivno preporučuje migraciju na NavigationStack (za push-navigaciju) ili NavigationSplitView (za master-detail). Legacy kod još radi, ali novi projekti ga ne smiju koristiti.
Kako se vratiti na root ekran u NavigationStack?
Ako koristiš NavigationPath, napiši path = NavigationPath(). Ako koristiš typed array kao [Route], napiši path.removeAll(). Obje varijante animirano poništavaju cijeli stog u jednoj liniji koda.
Kada koristiti NavigationStack, a kada NavigationSplitView?
Koristi NavigationStack za aplikacije s jednim stupcem (klasičan iPhone push flow, watchOS). Koristi NavigationSplitView za master-detail iskustva koja se trebaju adaptivno širiti na iPadu i Macu. Uobičajen produkcijski uzorak je NavigationSplitView vani, NavigationStack unutar detail stupca.
Zašto se moj NavigationStack automatski vraća na root?
Najčešći uzrok je da roditeljski ObservableObject koji drži path state rebuildaš, pa nova instanca dobije praznu putanju i stack se resetira. Premjesti navigacijski state u izdvojen @Observable router i drži ga u .environment(_:), ne kao @State unutar pogleda koji se često rerenderiraju.
Kako implementirati deep linking s NavigationStack?
U App struct-u dodaj .onOpenURL { url in ... }, parsiraj URL u Route vrijednost (obično enum s Hashable conformance), i appendaj na router.path. Osiguraj da su .navigationDestination(for:) modifikatori mount-ani prije nego što napraviš append, inače push se tiho ignorira.
Vodič za izradu Live Activities i Dynamic Island u iOS 26 pomoću ActivityKit okvira. Postavljanje Widget ekstenzije, ActivityAttributes, sva tri stanja Dynamic Islanda, App Intents i testiranje u Xcode 26 simulatoru.
Praktičan vodič kroz TipKit u iOS 26: kako izraditi savjete, definirati pravila, koristiti TipGroup, sinkronizirati stanje preko iClouda i lokalizirati poruke uz radne SwiftUI primjere.
App Intents u iOS 26 omogućuje da jedan Swift tip radi kao Siri naredba, interaktivni widget i Apple Intelligence akcija. Kompletan vodič s primjerima za Swift 6.