SwiftUI NavigationStack v iOS 26: Kompletní průvodce typově bezpečnou navigací (2026)

Kompletní průvodce SwiftUI NavigationStack v iOS 26: type-safe navigace, NavigationPath pro heterogenní typy, deep linking, state restoration přes SceneStorage a zoom transitions s Liquid Glass toolbarem. S ukázkami kódu a tipy z praxe.

SwiftUI NavigationStack iOS 26 Průvodce (2026)

Aktualizováno: 29. července 2026

NavigationStack ve SwiftUI je typově bezpečný navigační kontejner představený v iOS 16, který nahradil starý NavigationView a v iOS 26 získal plnou podporu pro Liquid Glass toolbar, zoom transitions a lépe strukturovanou state restoration. Pracuje s tzv. navigation path, což je pole hodnot, které určují, jaké obrazovky jsou aktuálně na zásobníku. Díky tomu můžete navigaci ovládat programaticky, obnovit ji po restartu aplikace a bezpečně předávat data mezi obrazovkami bez řetězce optionálů. Já sama používám NavigationStack jako výchozí kontejner v každé nové SwiftUI aplikaci a v tomhle průvodci ukážu, proč, a jak se v něm nespálit.

  • NavigationStack nahrazuje NavigationView od iOS 16 a je jediný oficiálně podporovaný způsob push-based navigace ve SwiftUI 2026.
  • Modifikátor navigationDestination(for:) registruje typ hodnoty, kterou zásobník umí zobrazit, a díky tomu je navigace type-safe.
  • NavigationPath umí type-erasovat heterogenní typy, takže na jednom zásobníku můžete mít User, Order i URL současně.
  • Kombinace @SceneStorage a Codable path zaručí obnovení hluboko zanořeného stavu po killu aplikace nebo přepnutí scény.
  • V iOS 18 a novějších přinášejí navigationTransition(.zoom(...)) a matched geometry animace bez custom kódu.
  • Pro rozdělené split-view rozhraní na iPadu a Macu použijte NavigationSplitView. NavigationStack uvnitř druhé kolony je stále idiomatický.

Tak, začněme od začátku. NavigationView byl v iOS 13 až 15 způsob, jak dostat push navigaci do SwiftUI, ale s ním přišel i známý problém: modifikátor NavigationLink(isActive:) se choval nepředvídatelně na iPadu, split-view režim se nedal spolehlivě přepnout a hluboká navigace (5+ obrazovek) občas ztratila stav. Od iOS 16 je NavigationView označen jako deprecated a Apple ho v roce 2026 aktivně odstraňuje ze všech oficiálních příkladů.

NavigationStack ho nahrazuje jednoznačně a přináší tři konkrétní výhody, na kterých mi jako SwiftUI inženýrce záleží nejvíc:

  • Type-safe destinations. Místo aby každý NavigationLink nesl svůj cílový view uvnitř sebe, registrujete typ hodnoty jednou přes navigationDestination(for:) a link předává jen data.
  • Deklarativní path. Zásobník obrazovek je pole hodnot, které můžete kdykoli přečíst, modifikovat, uložit a načíst.
  • Předvídatelné chování na všech platformách. Push, pop, deep link i state restoration se chovají stejně na iPhonu, iPadu i v Catalyst aplikaci.

Pokud stále udržujete kód s NavigationView, migrace je většinou mechanická. Obalíte obsah do NavigationStack, přesunete NavigationLink(destination:) na NavigationLink(value:) a přidáte navigationDestination(for:). V praxi je to hodina práce na středně velkou aplikaci a odměnou je konec s tou zvláštní chvilkou, kdy vám iPad simulátor při rotaci zapomene, kde v zásobníku jste.

Základní recept, který používám v každé nové obrazovce, vypadá takto. NavigationStack obaluje kořen, NavigationLink(value:) pushuje hodnotu do path a navigationDestination(for:) říká, jaký view se má pro daný typ vytvořit. Klíčová myšlenka je, že NavigationLink už nedrží destination view. Drží jen hodnotu, kterou zásobník umí interpretovat. Tenhle rozdíl je důvodem, proč navigace najednou funguje předvídatelně.

import SwiftUI

struct Recipe: Hashable, Identifiable {
    let id: UUID
    let name: String
    let cookMinutes: Int
}

struct RecipeListView: View {
    let recipes: [Recipe]

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                // Link nese hodnotu, ne cilovy view
                NavigationLink(value: recipe) {
                    RecipeRow(recipe: recipe)
                }
            }
            .navigationTitle("Recepty")
            // Destinace se registruje jednou pro cely typ
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

Všimněte si dvou věcí. Za prvé, Recipe musí být Hashable, protože NavigationStack používá hash hodnot ke sledování zásobníku a k diffování při animacích. Za druhé, navigationDestination(for:) musí být uvnitř NavigationStack, ale ne uvnitř nějakého kondicionálního bloku jako if nebo ForEach. Když jsem to poprvé viděla, přišlo mi to jako umělé omezení, ale je to nutné. SwiftUI musí destinaci znát v okamžiku, kdy se pushuje hodnota, ne až když se view zobrazí.

Programatický push a pop

Když chcete zásobníkem manipulovat z kódu (třeba po dokončení onboardingu skočit do detailu), bindněte path zvenčí:

@State private var path: [Recipe] = []

var body: some View {
    NavigationStack(path: $path) {
        RecipeListView(recipes: recipes)
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
    }
    .onChange(of: featuredRecipe) { _, new in
        if let new { path = [new] }
    }
}

// Pop na koren odkudkoli:
func popToRoot() { path.removeAll() }

Tohle je jedna z věcí, kterou jsem nemohla ve staré API rozumně napsat bez skrytých bugů. Teď je pop-to-root doslova jeden řádek. Podobně můžu naplnit path pěti položkami a SwiftUI rovnou vytvoří pěti-úrovňový zásobník i s animací. Ideální pro obnovu stavu po deep linku.

Pole [Recipe] funguje skvěle, dokud je celý zásobník tvořený jedním typem. V reálné aplikaci ale obvykle chcete mít smíchané typy: z detailu receptu jdete na profil autora, z profilu na seznam jeho dalších receptů a odtamtud na komentář. Přesně pro tenhle případ existuje NavigationPath, type-erasovaný kontejner, do kterého strčíte cokoli Hashable:

@State private var path = NavigationPath()

var body: some View {
    NavigationStack(path: $path) {
        RecipeListView(recipes: recipes)
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
            .navigationDestination(for: Author.self) { author in
                AuthorProfileView(author: author)
            }
            .navigationDestination(for: Comment.self) { comment in
                CommentThreadView(comment: comment)
            }
    }
}

// Kdekoli v aplikaci:
func openAuthor(_ author: Author) {
    path.append(author)
}

Pro každý typ registrujete vlastní navigationDestination(for:). Pořadí registrace nehraje roli. NavigationPath je také Codable, což se pojí s dalším trikem, který přijde v sekci o state restoration.

Deep linking a otevírání URL v NavigationStack

Deep linking je v NavigationStack překvapivě přímočarý, protože zásobník je jen pole hodnot. Když aplikace obdrží URL přes onOpenURL nebo Universal Link, přeložíte URL na sekvenci hodnot a přiřadíte je do path. SwiftUI se postará o animovaný push, a to i když je aplikace v chladném startu.

@State private var path = NavigationPath()

var body: some View {
    NavigationStack(path: $path) { /* ... */ }
        .onOpenURL { url in
            // swiftcrafted://recipe/42/comments/17
            guard url.scheme == "swiftcrafted" else { return }
            let parts = url.pathComponents.filter { $0 != "/" }
            var newPath = NavigationPath()

            if parts.first == "recipe", let id = parts.dropFirst().first,
               let recipe = repo.recipe(id: id) {
                newPath.append(recipe)
            }
            if parts.contains("comments"),
               let commentId = parts.last,
               let comment = repo.comment(id: commentId) {
                newPath.append(comment)
            }
            path = newPath
        }
}

Podobně můžete reagovat na NSUserActivity z Handoff nebo Spotlight vyhledávání. Doporučuji vytáhnout dekódování URL do samostatné funkce DeepLinkResolver, kterou pak snadno pokryjete unit testy. Zásobník je pak jen expect(resolver.resolve(url)) == [.recipe(42), .comment(17)]. Podrobněji se dekódování URL věnuji v článku o SwiftUI WebView pro iOS 26, který používá podobný vzor pro interní odkazy.

Obnovení stavu navigace přes SceneStorage

Když uživatel zavře aplikaci a systém ji za tři dny killne, očekává, že po znovuotevření skončí přesně tam, kde skončil. To je state restoration. Ve staré API to byla noční můra, museli jste ručně serializovat každou obrazovku přes NSUserActivity. S NavigationStack je to jedna anotace navíc, protože NavigationPath umí říct codable:

struct RootView: View {
    @SceneStorage("recipes.path") private var pathData: Data?
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) { /* ... */ }
            .task {
                if let data = pathData,
                   let representation = try? JSONDecoder()
                       .decode(NavigationPath.CodableRepresentation.self, from: data) {
                    path = NavigationPath(representation)
                }
            }
            .onChange(of: path) { _, newPath in
                guard let representation = newPath.codable else { return }
                pathData = try? JSONEncoder().encode(representation)
            }
    }
}

Aby to fungovalo, musí být všechny typy v path Codable, ne jen Hashable. Pokud pushujete reference typy (třeba @Observable model), místo celého objektu ukládejte ID a v navigationDestination ho znovu načtěte z repository. Já tenhle vzor používám důsledně (ukládat identifikátory, ne objekty) a je to jeden z hlavních důvodů, proč mi aplikace přežívají restart s hluboko zanořeným stavem. Podrobnější popis @Observable a bindingu najdete v článku Migrace z ObservableObject na @Observable.

Otázka, kterou dostávám nejčastěji: kdy použít NavigationStack a kdy NavigationSplitView? Krátká odpověď. NavigationSplitView je pro rozhraní se dvěma nebo třemi paralelními kolonami (sidebar, seznam, detail), typicky na iPadu a Macu. NavigationStack je pro push-based navigaci uvnitř jedné kolony. V praxi je běžné je kombinovat: NavigationSplitView jako obálku a NavigationStack uvnitř detail kolony pro dál-do-hloubky.

AspektNavigationStackNavigationSplitView
Typický layoutPush-based, jedna kolonaSidebar + content + detail (2 nebo 3 kolony)
Cílové platformyiPhone (primárně), iPad, MaciPad, Mac (na iPhonu kolabuje do stacku)
Path management[Value] nebo NavigationPath@State selection pro každou kolonu
Deep linkingNaplnění path polem hodnotNastavení selectionu v každé koloně
Ideální use caseOnboarding, wizard, seznam→detail→ještě víc detailMail, Notes, aplikace s trvale viditelným sidebarem
State restorationCodable path do SceneStorageCodable selection do SceneStorage

Osobní pravidlo, které používám: začnu vždycky s NavigationStack. Pokud aplikace poroste do sidebar-driven rozhraní, přidám NavigationSplitView obálku a upravím layout jen na iPadu přes @Environment(\.horizontalSizeClass). Opačným směrem (začít se NavigationSplitView a pak ho odebírat) je bolestivější, protože musíte vytrhnout selection API a nahradit ho path.

Přístupnost, VoiceOver a Rotor v navigaci

Tady musím být přímá. NavigationStack se sám o sobě chová dobře s VoiceOverem, ale to neznamená, že přístupnost je zadarmo. Když uživatel s VoiceOverem otevře novou obrazovku, systém oznámí navigationTitle jako první focus point. Když ho neuvedete, VoiceOver oznámí jen "Back button", a to je frustrující zážitek. Vždycky nastavte navigationTitle, i když ho vizuálně skryjete přes .toolbar(.hidden, for: .navigationBar).

RecipeDetailView(recipe: recipe)
    .navigationTitle(recipe.name)
    .navigationBarTitleDisplayMode(.inline)
    .toolbar {
        ToolbarItem(placement: .primaryAction) {
            Button {
                share(recipe)
            } label: {
                Label("Sdilet recept", systemImage: "square.and.arrow.up")
            }
            .accessibilityHint("Otevre sdileci panel systemu")
        }
    }

Dvě další věci, které dělám v každém projektu:

  • Popiš toolbar tlačítka. SF Symbol samo o sobě VoiceOver nepřečte smysluplně. Použijte Label místo holé Image, protože Label nese text pro asistivní technologie i tehdy, když je vizuálně skrytý.
  • Accessibility Rotor pro dlouhé seznamy. Pokud zásobník obsahuje seznam receptů, přidejte .accessibilityRotor("Nedávno přidané", entries: recentRecipes) { $0.name }. VoiceOver uživatel se tak dostane k důležitým položkám bez skrolování celým seznamem.

Zvlášť si dávám pozor na animované přechody. Když uživatel má zapnuté Reduce Motion, měl by navigationTransition(.zoom(...)) spadnout na standardní fade. SwiftUI to od iOS 18 dělá automaticky, ale u vlastních přechodů to musíte obalit @Environment(\.accessibilityReduceMotion). Přístupnost není přidaná vrstva, je součást API, které používáte každý den.

Zoom transitions a Liquid Glass toolbar v iOS 26

iOS 18 přinesl navigationTransition(.zoom(sourceID:in:)) a v iOS 26 se z něj stal defaultní vzor pro přechod ze seznamu na detail. Efekt je jednoduchý (miniatura v seznamu se "rozroste" do detailu), ale bez API by šlo o hodiny custom matched-geometry kódu. S NavigationStack stačí označit zdrojový view a cíl:

@Namespace private var recipeNamespace

var body: some View {
    NavigationStack {
        List(recipes) { recipe in
            NavigationLink(value: recipe) {
                RecipeRow(recipe: recipe)
                    .matchedTransitionSource(id: recipe.id, in: recipeNamespace)
            }
        }
        .navigationDestination(for: Recipe.self) { recipe in
            RecipeDetailView(recipe: recipe)
                .navigationTransition(.zoom(sourceID: recipe.id, in: recipeNamespace))
        }
    }
}

Honestly, tohle je jedna z těch věcí, kde nová API vypadá jako magie. V iOS 26 se navíc NavigationStack plně integruje s Liquid Glass toolbarem. Pozadí toolbaru se dynamicky rozostřuje podle obsahu za ním a přechod mezi obrazovkami zachovává glass efekt bez blikání. Detailně to popisuji v článku Liquid Glass ve SwiftUI. Pokud používáte custom ToolbarBackground, na iOS 26 ho většinou můžete odstranit. Systém vybere správné pozadí sám.

Pro hlubší popis chování zásobníku, možností NavigationPath a všech modifikátorů doporučuji referenční dokumentaci Apple: NavigationStack — Apple Developer Documentation a pro rozdělené layouty NavigationSplitView reference. Návrhové principy (kdy vůbec push navigaci použít, kdy raději sheet) najdete v Human Interface Guidelines: Navigation.

Často kladené otázky

Musí být hodnoty v NavigationPath vždy Codable?

Pouze pokud potřebujete state restoration nebo serializaci path. Pro běžnou push navigaci stačí Hashable. Když ale voláte path.codable, SwiftUI vrátí nil, pokud v path najde jediný typ, který Codable neimplementuje. Proto doporučuji do path ukládat identifikátory místo celých modelových objektů.

Jak zjistím, kolik obrazovek je aktuálně na zásobníku?

Když držíte path jako @State, stačí path.count. Pro NavigationPath to funguje stejně. Pokud path nebindíte a spoléháte se na defaultní chování NavigationStack, hloubku zvenčí nezjistíte. Proto pro netriviální aplikace vždycky doporučuji path bindovat.

Můžu použít NavigationStack uvnitř TabView?

Ano a je to doporučený vzor. Každý tab by měl mít vlastní NavigationStack s vlastní path. SwiftUI si drží stav pro každý tab zvlášť a přepnutí tabu neresetuje zásobník. Pozor jen na to, aby navigationDestination byl uvnitř každého NavigationStack, ne mimo TabView.

Proč se mi NavigationStack resetuje při každé aktualizaci dat?

Nejčastější příčina je, že path je uložený v předkovi, který se rekonstruuje při změně modelu. Pokud NavigationStack obalíte view, který má vlastní @State, ale předek ho pokaždé vytváří znovu, path se ztratí. Řešení: přesuňte path do view, který se nerekonstruuje, nebo do @Observable třídy vloženého přes @Environment.

Jak přidat vlastní přechod mezi obrazovkami?

Použijte navigationTransition(_:) na destination view. Kromě vestavěného .zoom(sourceID:in:) můžete od iOS 18 implementovat vlastní přechod přes protokol NavigationTransition. Vždycky ale respektujte Reduce Motion. Buď použijte automaticky reagující .zoom, nebo pro custom přechod podmiňte animaci přes \.accessibilityReduceMotion.

Ava Thompson
O Autorovi Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.