SwiftUI NavigationStack in iOS 26: Guida a Routing Type-Safe e Deep Linking (2026)

Guida pratica a NavigationStack in SwiftUI iOS 26: routing type-safe con NavigationPath, deep linking, pattern Router con @Observable, accessibilità VoiceOver ed errori comuni da evitare in produzione.

NavigationStack SwiftUI iOS 26: Guida 2026

Aggiornato: 18 agosto 2026

NavigationStack è la moderna API di navigazione dichiarativa di SwiftUI, introdotta in iOS 16 e diventata l'unico strumento supportato ufficialmente da iOS 26: sostituisce completamente il vecchio NavigationView e permette un routing type-safe tramite NavigationPath, deep linking programmatico e integrazione nativa con il pattern Router basato su @Observable. In questa guida costruisco un'architettura di navigazione reale, spiegando come mantenerla accessibile a VoiceOver e come evitare le trappole più comuni che continuo a vedere nelle code review.

  • NavigationStack sostituisce NavigationView in tutti i nuovi progetti iOS 26: solo lo stack a colonna singola è coperto da modificatori come navigationDestination(for:).
  • NavigationPath permette di raccogliere destinazioni eterogenee Hashable in un unico stack serializzabile con Codable.
  • Il pattern Router con @Observable disaccoppia il modello di navigazione dalle view e rende testabile il deep linking.
  • Un solo navigationDestination(for:) per tipo dev'essere registrato al livello più alto dello stack, altrimenti SwiftUI ignora silenziosamente i push.
  • L'accessibilità della navigazione dipende da navigationTitle, accessibilityLabel sulle destinazioni e dalla gestione corretta del focus con @AccessibilityFocusState.
  • Il ripristino dello stato tramite SceneStorage richiede che tutti i tipi nel NavigationPath siano Codable.

Cos'è NavigationStack e perché ha sostituito NavigationView?

NavigationStack è un contenitore SwiftUI che gestisce una pila di view impilate gerarchicamente, dove ogni push corrisponde a una destinazione dichiarata in modo type-safe. Apple lo ha introdotto in iOS 16 e con iOS 26 NavigationView non è più semplicemente deprecato, è marcato come obsoleto: chiamarlo genera un warning che diventerà un errore nel prossimo ciclo maggiore. Il motivo di questa transizione radicale è che NavigationView mescolava due comportamenti incompatibili (stack a colonna singola su iPhone e split-view a doppia colonna su iPad) dietro la stessa API, causando bug di layout impossibili da risolvere.

L'architettura moderna divide le responsabilità in tre componenti distinti: NavigationStack per la navigazione gerarchica classica (Impostazioni, Wi-Fi, Rete), NavigationSplitView per interfacce a più colonne (Mail, File, Note), e TabView per la navigazione paritaria fra sezioni. Ognuno risolve un problema specifico e i tre possono essere composti liberamente. Il primo esempio minimo è già molto più espressivo di quello che si otteneva con NavigationView:

import SwiftUI

struct ArticleListView: View {
    let articles: [Article]

    var body: some View {
        NavigationStack {
            List(articles) { article in
                NavigationLink(value: article) {
                    Text(article.title)
                }
            }
            .navigationTitle("Articoli")
            .navigationDestination(for: Article.self) { article in
                ArticleDetailView(article: article)
            }
        }
    }
}

struct Article: Identifiable, Hashable {
    let id: UUID
    let title: String
    let body: String
}

La differenza fondamentale rispetto al vecchio approccio è che NavigationLink ora accetta un valore anziché una destinazione. È la view registrata con navigationDestination(for:) a decidere come renderizzare quel valore. Questo disaccoppiamento è ciò che rende possibile il deep linking e il ripristino dello stato: puoi ricostruire lo stack a partire da una sequenza di identificatori Hashable, senza dover mai istanziare view a mano.

NavigationPath è una struttura opaca che raccoglie destinazioni eterogenee di qualunque tipo Hashable, cancellando il tipo concreto ma preservando la capacità di serializzazione. È lo strumento che trasforma NavigationStack da giocattolo statico a motore di navigazione programmabile, perché ti dà un binding modificabile con append, removeLast e la proprietà count. Se dichiari lo stack come NavigationStack(path: $path), ogni modifica al path viene riflessa immediatamente nella UI con l'animazione di push o pop corretta.

import SwiftUI

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

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: Article.self) { article in
                    ArticleDetailView(article: article)
                }
                .navigationDestination(for: Author.self) { author in
                    AuthorProfileView(author: author)
                }
                .navigationDestination(for: SettingsRoute.self) { route in
                    SettingsView(route: route)
                }
        }
        .environment(\.pushArticle) { article in
            path.append(article)
        }
    }
}

enum SettingsRoute: Hashable, Codable {
    case account
    case privacy
    case notifications(category: String)
}

Puoi mescolare liberamente più tipi nello stesso path: SwiftUI userà il navigationDestination(for:) registrato per il tipo del valore in cima. La regola meno intuitiva è che i modificatori vanno applicati alla view root dello stack, non alla view spinta. Se dimentichi questa distinzione, il push apparentemente funziona (il path si aggiorna) ma la view di destinazione non compare mai. Onestamente, ci ho perso un pomeriggio intero prima di capire che stavo mettendo il modificatore nel punto sbagliato. In quel caso Xcode 26 stampa un runtime warning nel debugger, cosa che con iOS 25 non faceva.

Per serializzare lo stack (indispensabile per il ripristino su rilancio dell'app), assicurati che tutti i tipi contenuti siano Codable. NavigationPath espone un initializer init(_ codable:) e una proprietà codable che restituisce un rappresentazione persistibile. Combinandola con SceneStorage ottieni una gestione dello stato di navigazione praticamente gratuita:

@main
struct SwiftCraftedApp: App {
    @SceneStorage("navigation.path") private var pathData: Data?
    @State private var path = NavigationPath()

    var body: some Scene {
        WindowGroup {
            RootView(path: $path)
                .task { restore() }
                .onChange(of: path) { _, new in persist(new) }
        }
    }

    private func restore() {
        guard let data = pathData,
              let representation = try? JSONDecoder()
                .decode(NavigationPath.CodableRepresentation.self, from: data)
        else { return }
        path = NavigationPath(representation)
    }

    private func persist(_ path: NavigationPath) {
        guard let representation = path.codable,
              let data = try? JSONEncoder().encode(representation)
        else { return }
        pathData = data
    }
}

Deep linking con NavigationStack: universal link e URL scheme

Il deep linking in iOS 26 passa dal modificatore onOpenURL o, se usi App Intents, dal metodo perform(). In entrambi i casi la ricetta è la stessa: mappa l'URL o l'intent a una sequenza di valori Hashable, resetta il path e fai l'append. Grazie al tipo NavigationPath, non devi mai importare view nel layer di routing: solo modelli. Questa separazione è ciò che permette di scrivere test unitari per il deep linking senza montare la gerarchia SwiftUI.

extension RootView {
    func handle(url: URL) {
        guard url.scheme == "swiftcrafted" else { return }
        let components = url.pathComponents.dropFirst()

        path = NavigationPath()
        switch Array(components) {
        case ["article", let slug]:
            if let article = ArticleStore.shared.article(withSlug: slug) {
                path.append(article)
            }
        case ["author", let handle, "settings"]:
            if let author = AuthorStore.shared.author(handle: handle) {
                path.append(author)
                path.append(SettingsRoute.account)
            }
        default:
            break
        }
    }
}

Con gli Universal Link, la struttura è identica ma il parsing si concentra su URLComponents e sul dominio configurato in apple-app-site-association. Se vuoi combinare deep link e intelligenza contestuale, l'articolo sugli App Intents in iOS 26 con Siri e Spotlight mostra come esporre le stesse rotte a Siri e alla ricerca di sistema, riutilizzando il router che vedrai fra poco.

La navigazione programmatica in NavigationStack si riduce a tre operazioni sul path: append(value) per un push, removeLast() per un pop, e removeLast(path.count) per un reset alla root. Nessuna di queste operazioni richiede un NavigationLink nella vista, il che le rende ideali per flussi guidati come onboarding, checkout o modali multi-step. La cosa da tenere a mente è che NavigationPath conforma Equatable ma non espone gli elementi individuali: è opaco per design.

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

    var body: some View {
        NavigationStack(path: $path) {
            CartView(onCheckout: { path.append(CheckoutStep.shipping) })
                .navigationDestination(for: CheckoutStep.self) { step in
                    stepView(for: step)
                }
        }
    }

    @ViewBuilder
    private func stepView(for step: CheckoutStep) -> some View {
        switch step {
        case .shipping:
            ShippingView(onNext: { path.append(CheckoutStep.payment) })
        case .payment:
            PaymentView(onNext: { path.append(CheckoutStep.confirmation) })
        case .confirmation:
            ConfirmationView(onDone: { path.removeLast(path.count) })
        }
    }
}

enum CheckoutStep: Hashable, Codable {
    case shipping, payment, confirmation
}

Un errore che vedo continuamente nelle review è l'uso di NavigationLink(value:) dentro pulsanti che dovrebbero eseguire logica prima di navigare, tipo la validazione di un form o una chiamata di rete. In quei casi il push va comandato dal risultato della logica: usa un Button normale che, in caso di successo, esegue path.append(...). Combinare NavigationLink con validazioni asincrone porta a scenari dove il push parte prima che l'errore sia gestito, e non c'è modo di annullarlo in modo pulito.

Pattern Router con @Observable in iOS 26

Il pattern Router estrae il path e le rotte in un oggetto separato che sopravvive alle ricostruzioni della view. Con la macro @Observable introdotta in Swift 5.9 e potenziata in Swift 6.2, il router diventa una semplice classe di riferimento che SwiftUI osserva automaticamente. Ho scritto una guida dedicata sulla macro @Observable in SwiftUI per iOS 26 se vuoi approfondire il framework Observation; qui mi limito ad applicarlo alla navigazione.

import SwiftUI
import Observation

@Observable
final class Router {
    var path = NavigationPath()

    func push(_ value: any Hashable) {
        path.append(value)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func replace(with values: [any Hashable]) {
        path = NavigationPath()
        values.forEach { path.append($0) }
    }
}

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

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Article.self) { ArticleDetailView(article: $0) }
                .navigationDestination(for: Author.self) { AuthorProfileView(author: $0) }
        }
        .environment(router)
    }
}

struct ArticleRowButton: View {
    @Environment(Router.self) private var router
    let article: Article

    var body: some View {
        Button(article.title) { router.push(article) }
    }
}

Il vantaggio pratico non è l'eleganza, è la testabilità. Con un router iniettato tramite Environment, puoi scrivere unit test che verificano lo stato del path dopo una sequenza di deep link, senza montare la gerarchia SwiftUI. E per gli screenshot test o gli snapshot dell'onboarding, puoi iniettare un router con lo stack già popolato invece di simulare tap dell'utente. In un progetto recente ho ridotto i tempi di esecuzione della suite di UI test del 40% proprio grazie a questa separazione.

Accessibilità nella navigazione: VoiceOver e focus

Un'app SwiftUI ben scritta rispetta VoiceOver senza codice extra, ma la navigazione è il punto in cui le buone intenzioni si rompono. Ogni schermata deve dichiarare un navigationTitle non vuoto: VoiceOver lo legge automaticamente all'apertura, e senza di esso l'utente perde l'orientamento. In secondo luogo, se hai destinazioni con contenuto asincrono, sposta esplicitamente il focus sull'elemento principale usando @AccessibilityFocusState, altrimenti VoiceOver resta ancorato al pulsante che ha causato il push.

struct ArticleDetailView: View {
    let article: Article
    @AccessibilityFocusState private var isTitleFocused: Bool

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                Text(article.title)
                    .font(.largeTitle.bold())
                    .accessibilityAddTraits(.isHeader)
                    .accessibilityFocused($isTitleFocused)

                Text(article.body)
                    .font(.body)
            }
            .padding()
        }
        .navigationTitle(article.title)
        .navigationBarTitleDisplayMode(.large)
        .task {
            try? await Task.sleep(for: .milliseconds(300))
            isTitleFocused = true
        }
    }
}

Un secondo aspetto che continuo a trovare rotto è la back gesture. Se personalizzi la barra di navigazione con toolbar(.hidden) o rimpiazzi il pulsante indietro con uno custom, ricordati di preservare accessibilityLabel("Indietro") e di supportare l'escape gesture di VoiceOver (Z con due dita). Senza questa gesture, gli utenti VoiceOver rimangono bloccati nella destinazione. La regola generale che seguo è: non nascondere mai la navigazione senza fornire un'alternativa accessibile.

Errori comuni con NavigationStack e come risolverli

Dopo aver debuggato dozzine di regressioni di navigazione, sono arrivato a un elenco corto di errori che si ripresentano ogni volta. Il primo è duplicare navigationDestination(for:) per lo stesso tipo su più livelli dello stack: SwiftUI onora solo quello più vicino alla root e stampa un warning solo in iOS 26 (in iOS 17 falliva in silenzio). Il secondo è registrare la destinazione dentro una view spinta anziché sulla root, con lo stesso effetto: push apparentemente riuscito, schermata vuota.

Il terzo errore è cercare di leggere valori dal NavigationPath: non si può, ed è intenzionale. Se hai bisogno di ispezionare lo stack (per esempio per abilitare o disabilitare un pulsante in toolbar in base alla profondità), tieni una copia parallela tipizzata in un @Observable Router. Il quarto è mescolare @State con @SceneStorage in modo incoerente, provocando ripristini incompleti. La regola è: se il path può essere ripristinato, tutti i tipi che vi puoi mettere devono essere Codable, altrimenti il decoding fallisce e ricostruisci uno stack vuoto senza accorgertene.

Il quinto e ultimo, quello per cui ho letteralmente litigato in review, è usare NavigationLink annidati dentro liste all'interno di modali sheet. Uno sheet non eredita l'Environment del router se non lo passi esplicitamente, e la destinazione registrata all'esterno del sheet è invisibile dentro il modale. La soluzione è avvolgere il contenuto della sheet in un proprio NavigationStack con le proprie destinazioni, oppure passare esplicitamente il router con .environment(router).

Per confrontare le altre novità architetturali di iOS 26 che si intrecciano con la navigazione, dai un'occhiata alla guida su Liquid Glass in SwiftUI per iOS 26: molti dei nuovi effetti di transizione della barra si comportano diversamente dentro NavigationStack rispetto a NavigationSplitView. La documentazione di riferimento resta quella di Apple, che ho consultato scrivendo questa guida: la documentazione ufficiale di NavigationStack, la reference di NavigationPath e la sessione WWDC "The SwiftUI cookbook for navigation", ancora attuale nei principi anche se le API si sono evolute.

Domande frequenti

Qual è la differenza tra NavigationStack e NavigationView?

NavigationStack gestisce solo la navigazione gerarchica a colonna singola, con push/pop dichiarativi e destinazioni type-safe registrate via navigationDestination(for:). NavigationView, invece, cambiava comportamento a runtime fra stack e split-view a seconda del dispositivo: è deprecato dal iOS 16 e obsoleto in iOS 26. Per interfacce a più colonne, usa NavigationSplitView.

Posso avere più modificatori navigationDestination per lo stesso tipo?

No. SwiftUI onora solo il navigationDestination(for:) più vicino alla root dello NavigationStack per un dato tipo. Registrare la stessa destinazione due volte non causa un errore di compilazione, ma in iOS 26 il debugger stampa un runtime warning e i push successivi al primo vengono ignorati.

Come si ripristina lo stato di navigazione al rilancio dell'app?

Serializza NavigationPath.codable in un SceneStorage (o in UserDefaults) e ricostruiscilo con l'initializer NavigationPath(_ codable:). Condizione obbligatoria: tutti i tipi contenuti nel path devono conformarsi a Codable, altrimenti la decodifica fallisce e viene ricostruito uno stack vuoto.

Perché NavigationLink non funziona dentro un NavigationStack?

Nella quasi totalità dei casi hai registrato navigationDestination(for:) per un tipo sbagliato o su una view spinta anziché sulla root dello stack. Verifica che il tipo del valore in NavigationLink(value:) corrisponda esattamente a quello dichiarato in navigationDestination(for:), e sposta il modificatore sulla root.

Quando conviene usare NavigationStack invece di NavigationSplitView?

Usa NavigationStack per flussi lineari a colonna singola (Impostazioni, checkout, dettaglio articolo), e NavigationSplitView per interfacce a più colonne che devono adattarsi a iPad, macOS e schermi grandi (Mail, File, Note). Puoi anche annidare uno NavigationStack come contenuto della colonna di dettaglio in uno NavigationSplitView.

Ava Thompson
Sull'Autore Ava Thompson

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