Pattern di NavigationStack in SwiftUI per flussi multi-step prevedibili
Pattern di NavigationStack in SwiftUI per flussi multi-step, con routing chiaro, comportamento Back sicuro ed esempi pratici per onboarding e wizard di approvazione.

Cosa va storto nei flussi multi-step
Un flusso multi-step è qualsiasi sequenza in cui il passo 1 deve avvenire prima che il passo 2 abbia senso. Esempi comuni includono l'onboarding, una richiesta di approvazione (revisione, conferma, invio) e la raccolta dati in stile wizard dove si costruisce una bozza attraverso più schermate.
Questi flussi sembrano facili solo quando il Back si comporta come le persone si aspettano. Se il Back li porta in un posto sorprendente, gli utenti smettono di fidarsi dell'app. Questo si traduce in invii sbagliati, onboarding abbandonati e ticket di supporto come “Non riesco a tornare alla schermata su cui ero.”
La navigazione disordinata di solito appare in uno di questi modi:
- L'app salta alla schermata sbagliata o esce dal flusso troppo presto.
- La stessa schermata appare due volte perché è stata pushata due volte.
- Un passo si resetta tornando indietro e l'utente perde la bozza.
- L'utente può raggiungere il passo 3 senza completare il passo 1, creando uno stato invalido.
- Dopo un deep link o un riavvio dell'app, l'app mostra la schermata giusta ma con i dati sbagliati.
Un modello mentale utile: un flusso multi-step è due cose che si muovono insieme.
Prima, una pila di schermate (quello attraverso cui l'utente può tornare indietro). Secondo, lo stato condiviso del flusso (dati di bozza e progresso che non dovrebbero sparire solo perché una vista scompare).
Molte configurazioni di NavigationStack collassano quando la pila di schermate e lo stato del flusso si discostano. Per esempio, un onboarding potrebbe pushare “Crea profilo” due volte (rotte duplicate), mentre la bozza del profilo vive dentro la vista e viene ricreata al re-render. L'utente preme Back, vede una versione diversa del form e assume che l'app sia inaffidabile.
Un comportamento prevedibile inizia con il nominare il flusso, definire cosa dovrebbe fare il Back a ogni passo e dare allo stato del flusso una casa chiara.
Fondamenti di NavigationStack che ti servono davvero
Per i flussi multi-step, usa NavigationStack invece del più vecchio NavigationView. NavigationView può comportarsi diversamente tra versioni iOS ed è più difficile da ragionare quando fai push, pop o ripristini schermate. NavigationStack è l'API moderna che tratta la navigazione come una vera pila.
Un NavigationStack conserva la cronologia di dove l'utente è stato. Ogni push aggiunge una destinazione alla pila. Ogni azione di back rimuove una destinazione. Quella regola semplice è ciò che rende un flusso stabile: l'interfaccia dovrebbe rispecchiare una sequenza chiara di passaggi.
Cosa contiene davvero la stack
SwiftUI non memorizza i tuoi oggetti View. Conserva i dati che hai usato per navigare (il valore della rotta) e li usa per ricostruire la vista di destinazione quando serve. Questo ha alcune conseguenze pratiche:
- Non fare affidamento sul fatto che una vista rimanga viva per conservare dati importanti.
- Se una schermata ha bisogno di stato, tienilo in un modello (come un ObservableObject) che viva al di fuori della vista pushata.
- Se pushi la stessa destinazione due volte con dati diversi, SwiftUI le tratta come voci diverse nella pila.
NavigationPath è lo strumento da usare quando il tuo flusso non è solo una o due push fisse. Pensalo come una lista modificabile di valori “dove stiamo andando”. Puoi appendere rotte per avanzare, rimuovere l'ultima rotta per tornare indietro, o sostituire l'intero path per saltare a uno step successivo.
È adatto quando hai bisogno di passi in stile wizard, devi resettare il flusso dopo il completamento, o vuoi ripristinare un flusso parziale da uno stato salvato.
Prevedibile batte intelligente. Meno regole nascoste (salti automatici, pop impliciti, effetti collaterali guidati dalla vista) significa meno strani bug di back stack in seguito.
Modella il flusso con un piccolo enum di rotte
La navigazione prevedibile parte da una decisione: tieni il routing in un solo posto e rendi ogni schermata del flusso un valore piccolo e chiaro.
Crea una fonte unica di verità, come un FlowRouter (un ObservableObject) che possiede il NavigationPath. Questo mantiene coerenti ogni push e pop, invece di spargere la navigazione nelle viste.
Una struttura semplice di router
Usa un enum per rappresentare gli step. Aggiungi associated values solo per identificatori leggeri (come ID), non per interi modelli.
enum Step: Hashable {
case welcome
case profile
case verifyCode(phoneID: UUID)
case review(applicationID: UUID)
case done
}
final class FlowRouter: ObservableObject {
@Published var path = NavigationPath()
func go(_ step: Step) { path.append(step) }
func back() { if !path.isEmpty { path.removeLast() } }
func reset() { path = NavigationPath() }
}
Tieni lo stato del flusso separato dallo stato di navigazione
Tratta la navigazione come “dove si trova l'utente” e lo stato del flusso come “cosa ha inserito finora”. Metti i dati del flusso in uno store separato (ad esempio OnboardingState con nome, email, documenti caricati) e mantienilo stabile mentre le schermate vanno e vengono.
Una regola semplice:
FlowRouter.pathcontiene solo valoriStep.OnboardingStatecontiene gli input dell'utente e i dati di bozza.- Gli step portano ID per cercare i dati, non i dati stessi.
Questo evita hashing fragili, path enormi e reset a sorpresa quando SwiftUI ricostruisce le viste.
Passo dopo passo: costruire un wizard con NavigationPath
Per schermate in stile wizard, l'approccio più semplice è controllare la pila da te. Punta a una sola fonte di verità per “dove sono nel flusso?” e un solo modo per andare avanti o indietro.
Inizia con un NavigationStack(path:) legato a un NavigationPath. Ogni schermata pushata è rappresentata da un valore (spesso un caso enum) e registri le destinazioni una sola volta.
import SwiftUI
enum WizardRoute: Hashable {
case profile
case verifyEmail
case permissions
case review
}
struct OnboardingWizard: View {
@State private var path = NavigationPath()
@State private var currentIndex = 0
private let steps: [WizardRoute] = [.profile, .verifyEmail, .permissions, .review]
var body: some View {
NavigationStack(path: $path) {
StartScreen {
goToStep(0) // push first step
}
.navigationDestination(for: WizardRoute.self) { route in
switch route {
case .profile:
ProfileStep(onNext: { goToStep(1) })
case .verifyEmail:
VerifyEmailStep(onNext: { goToStep(2) })
case .permissions:
PermissionsStep(onNext: { goToStep(3) })
case .review:
ReviewStep(onEditProfile: { popToStep(0) })
}
}
}
}
private func goToStep(_ index: Int) {
currentIndex = index
path.append(steps[index])
}
private func popToStep(_ index: Int) {
let toRemove = max(0, currentIndex - index)
if toRemove > 0 { path.removeLast(toRemove) }
currentIndex = index
}
}
Per mantenere il Back prevedibile, segui poche abitudini. Appendi esattamente una rotta per avanzare, mantieni “Avanti” lineare (spingi solo lo step successivo) e quando ti serve saltare indietro (come “Modifica profilo” da Review), riduci la pila a un indice noto.
Questo evita schermate duplicate accidentali e fa sì che il Back corrisponda a ciò che gli utenti si aspettano: un tap equivale a un passo.
Mantieni i dati stabili mentre le schermate vanno e vengono
Un flusso multi-step sembra inaffidabile quando ogni schermata possiede il proprio stato. Scrivi un nome, vai avanti, torni indietro e il campo è vuoto perché la vista è stata ricreata.
La soluzione è semplice: tratta il flusso come un unico oggetto di bozza e lascia che ogni step lo modifichi.
In SwiftUI, di solito significa un ObservableObject condiviso creato una sola volta all'inizio del flusso e passato a ogni step. Non memorizzare i valori di bozza in @State di ogni vista a meno che non appartengano davvero solo a quella schermata.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Crealo nel punto di ingresso, poi condividilo con @StateObject e @EnvironmentObject (o passalo esplicitamente). Ora la pila può cambiare senza perdere dati.
Decidi cosa sopravvive alla navigazione indietro
Non tutto dovrebbe persistere per sempre. Decidi le tue regole in anticipo così il flusso rimane coerente.
Conserva gli input dell'utente (campi di testo, toggle, selezioni) a meno che non vengano resettati esplicitamente. Resetta lo stato UI specifico dello step (spinner di caricamento, alert temporanei, brevi animazioni). Cancella campi sensibili (come codici monouso) quando si lascia quello step. Se una scelta modifica passi successivi, cancella solo i campi dipendenti.
La validazione si adatta naturalmente qui. Invece di lasciare che gli utenti vadano avanti e poi mostrare un errore nella schermata successiva, mantienili nello step corrente finché non è valido. Disabilitare il pulsante basandosi su una proprietà calcolata come canGoNextFromProfile spesso basta.
Salva checkpoint senza esagerare
Alcune bozze possono vivere solo in memoria. Altre dovrebbero sopravvivere a riavvii o crash. Un default pratico:
- Mantieni i dati in memoria mentre l'utente sta attivamente avanzando nei passaggi.
- Persisti localmente in milestone chiare (account creato, approvazione inviata, pagamento iniziato).
- Persisti prima se il flusso è lungo o l'inserimento dati richiede più di un minuto.
In questo modo le schermate possono andare e venire liberamente, e il progresso dell'utente continua a sembrare stabile e rispettoso del suo tempo.
Deep link e ripristino di un flusso parzialmente completato
I deep link contano perché i flussi reali raramente iniziano allo step 1. Qualcuno tocca un'email, una notifica push o un link condiviso e si aspetta di atterrare sulla schermata giusta, come lo step 3 dell'onboarding o la schermata finale di approvazione.
Con NavigationStack, tratta un deep link come istruzioni per costruire un path valido, non come un comando per saltare a una vista. Parti dall'inizio del flusso e aggiungi solo gli step che sono veri per questo utente e questa sessione.
Trasforma un link esterno in una sequenza di rotte sicura
Un buon pattern è: parse dell'ID esterno, carica i dati minimi necessari, poi converti il risultato in una sequenza di rotte.
enum Route: Hashable {
case start
case profile
case verifyEmail
case approve(requestID: String)
}
func pathForDeepLink(requestID: String, hasProfile: Bool, emailVerified: Bool) -> [Route] {
var routes: [Route] = [.start]
if !hasProfile { routes.append(.profile) }
if !emailVerified { routes.append(.verifyEmail) }
routes.append(.approve(requestID: requestID))
return routes
}
Quei controlli sono le tue reti di sicurezza. Se mancano prerequisiti, non abbandonare l'utente sullo step 3 con un errore e senza via d'uscita. Portalo allo step mancante più vicino e assicurati che lo stack di back racconti ancora una storia coerente.
Ripristinare un flusso parzialmente completato
Per ripristinare dopo un riavvio, salva due cose: l'ultimo stato di rotta noto e i dati di bozza inseriti dall'utente. Poi decidi come riprendere senza sorprendere le persone.
Se la bozza è fresca (minuti o ore), offri una scelta chiara “Riprendi”. Se è vecchia, inizia dall'inizio ma usa la bozza per precompilare i campi. Se i requisiti sono cambiati, ricostruisci il path usando le stesse reti di sicurezza.
Push vs modal: mantieni il flusso facile da uscire
Un flusso è prevedibile quando c'è un modo principale per andare avanti: pushare schermate su una singola pila. Usa sheet e full-screen cover per attività laterali, non per il percorso principale.
Il push (NavigationStack) si adatta quando l'utente si aspetta che Back ripercorra i suoi passi. I modal (sheet o fullScreenCover) vanno bene per compiti laterali, scelte rapide o conferme di azioni rischiose.
Un semplice insieme di regole previene la maggior parte delle stranezze di navigazione:
- Push per il percorso principale (Step 1, Step 2, Step 3).
- Usa uno sheet per compiti opzionali (scegli una data, seleziona un paese, scansiona un documento).
- Usa fullScreenCover per “mondi separati” (login, cattura camera, un lungo documento legale).
- Usa un modal per conferme (cancella flusso, elimina bozza, invia per approvazione).
L'errore comune è mettere schermate principali in sheet. Se lo Step 2 è uno sheet, l'utente può chiuderlo con uno swipe, perdere contesto e trovarsi con uno stack che dice che è ancora allo Step 1 mentre i dati indicano che ha completato lo Step 2.
Le conferme sono l'opposto: pushare una schermata “Sei sicuro?” nel wizard ingombra la pila e può creare loop (Step 3 -> Conferma -> Back -> Step 3 -> Back -> Conferma).
Come chiudere tutto pulitamente dopo “Done”
Decidi prima cosa significa “Done”: tornare alla home, tornare alla lista o mostrare una schermata di successo.
Se il flusso è stato pushato, resetta il tuo NavigationPath a vuoto per tornare all'inizio. Se il flusso è stato presentato come modal, chiama dismiss() dall'environment. Se hai entrambi (un modal che contiene un NavigationStack), dismetti il modal, non ogni schermata pushata. Dopo un submit riuscito, cancella anche lo stato di bozza in modo che un flusso riaperto inizi pulito.
Comportamento del pulsante Back e momenti “Sei sicuro?”
Per la maggior parte dei flussi multi-step, la scelta migliore è non fare nulla: lascia che il pulsante Back di sistema (e il gesto swipe-back) funzionino. Corrisponde alle aspettative degli utenti ed evita bug in cui l'UI dice una cosa ma lo stato di navigazione un'altra.
L'intercettazione vale solo quando tornare indietro causerebbe un danno reale, come perdere un lungo form non salvato o abbandonare un'azione irreversibile. Se l'utente può tornare indietro in sicurezza e continuare, non aggiungere attrito.
Un approccio pratico è mantenere la navigazione di sistema, ma aggiungere una conferma solo quando lo schermo è “dirty” (modificato). Significa fornire la tua azione di back e chiedere una volta, con una via d'uscita chiara.
@Environment(\.dismiss) private var dismiss
@State private var showLeaveConfirm = false
let hasUnsavedChanges: Bool
var body: some View {
Form { /* fields */ }
.navigationBarBackButtonHidden(hasUnsavedChanges)
.toolbar {
if hasUnsavedChanges {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") { showLeaveConfirm = true }
}
}
}
.confirmationDialog("Discard changes?", isPresented: $showLeaveConfirm) {
Button("Discard", role: .destructive) { dismiss() }
Button("Keep Editing", role: .cancel) {}
}
}
Evita che questo diventi una trappola:
- Chiedi solo quando puoi spiegare la conseguenza in una frase breve.
- Offri un'opzione sicura (Annulla, Continua a modificare) più un'uscita chiara (Elimina, Esci).
- Non nascondere i pulsanti di back a meno che tu non li sostituisca con un evidente Back o Close.
- Preferisci confermare l'azione irreversibile (come “Approva”) invece di bloccare la navigazione ovunque.
Se ti ritrovi a combattere spesso il gesto di back, di solito è un segnale che il flusso necessita di autosave, una bozza salvata o passi più piccoli.
Errori comuni che creano stack di back strani
La maggior parte dei “perché è tornato lì?” bug non sono dovuti a SwiftUI che fa scelte casuali. Derivano spesso da pattern che rendono lo stato di navigazione instabile. Per un comportamento prevedibile, tratta lo stack di back come dati dell'app: stabile, testabile e posseduto da un solo posto.
Pile extra accidentali
Un tranello comune è ritrovarsi con più di un NavigationStack senza accorgersene. Per esempio, ogni tab ha la sua stack root, e poi una vista figlia aggiunge un'altra stack dentro il flusso. Il risultato è comportamento di back confuso, barre di navigazione mancanti o schermate che non fanno pop come ti aspetti.
Un altro problema frequente è ricreare il tuo NavigationPath troppo spesso. Se il path viene creato dentro una vista che si ri-renderizza, può resettarsi su cambi di stato e riportare l'utente allo step 1 dopo che ha digitato in un campo.
Gli errori dietro la maggior parte degli stack strani sono semplici:
- Nidificare NavigationStack dentro un'altra stack (spesso dentro tab o contenuti di sheet)
- Reinizializzare
NavigationPath()durante gli aggiornamenti di vista invece di tenerlo in uno stato di lunga durata - Mettere valori non stabili nella tua rotta (come un oggetto modello che cambia), il che rompe
Hashablee causa destinazioni incongruenti - Spargere decisioni di navigazione tra handler di pulsanti finché nessuno riesce a spiegare cosa significa “next”
- Guidare il flusso da più sorgenti contemporaneamente (per esempio, sia un view model che una view mutano il path)
Se devi passare dati tra step, preferisci identificatori stabili nella rotta (ID, enum di step) e tieni i dati reali del form nello stato condiviso.
Un esempio concreto: se la tua rotta è .profile(User) e User cambia mentre la persona digita, SwiftUI può trattarlo come una rotta diversa e ricollegare la pila. Fai la rotta .profile e conserva i dati della bozza nel stato condiviso.
Check rapido per una navigazione prevedibile
Quando un flusso sembra fuori posto, di solito è perché lo stack di back non racconta la stessa storia dell'utente. Prima di lucidare l'UI, fai un rapido controllo delle regole di navigazione.
Testa su un dispositivo reale, non solo con le preview, e prova sia tocchi lenti che rapidi. I tocchi veloci spesso rivelano push duplicati e stato mancante.
- Torna indietro un passo alla volta dall'ultima schermata alla prima. Conferma che ogni schermata mostra gli stessi dati inseriti prima.
- Scatena Cancel da ogni step (inclusi primo e ultimo). Conferma che ritorna sempre a un posto sensato, non a una schermata precedente a caso.
- Forza la chiusura dell'app a metà flusso e rilancia. Assicurati di poter riprendere in sicurezza, sia ripristinando il path sia ricominciando da uno step noto con dati salvati.
- Apri il flusso con un deep link o un collegamento rapido. Verifica che lo step di destinazione sia valido; se mancano dati richiesti, reindirizza allo step più precoce che può raccoglierli.
- Finisci con Done e conferma che il flusso viene rimosso pulitamente. L'utente non dovrebbe poter premere Back e rientrare in un wizard completato.
Un modo semplice per testare: immagina un wizard di onboarding con tre schermate (Profile, Permissions, Confirm). Inserisci un nome, vai avanti, torna indietro, modificalo, poi salta a Confirm via deep link. Se Confirm mostra il nome vecchio, o se Back ti porta a un Profile duplicato, gli aggiornamenti del path non sono coerenti.
Se superi la checklist senza sorprese, il tuo flusso sembrerà calmo e prevedibile, anche quando gli utenti lo lasciano e ci tornano dopo.
Un esempio realistico e i prossimi passi
Immagina un flusso di approvazione manageriale per una richiesta di spesa. Ha quattro step: Review, Edit, Confirm e Receipt. L'utente si aspetta una cosa: Back va sempre allo step precedente, non a qualche schermata casuale visitata in precedenza.
Un semplice enum di rotte mantiene questo comportamento prevedibile. Il tuo NavigationPath dovrebbe memorizzare solo la rotta e eventuali identificatori piccoli necessari per ricaricare lo stato, come un expenseID e una mode (review vs edit). Evita di pushare modelli grandi e mutabili nel path perché rende fragili i restore e i deep link.
Mantieni la bozza di lavoro in una singola fonte di verità esterna alle viste, come un @StateObject flow model (o uno store). Ogni step legge e scrive quel modello, così le schermate possono apparire e scomparire senza perdere gli input.
Al minimo, stai tracciando tre cose:
- Rotte (per esempio:
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)) - Dati (un oggetto bozza con voci di riga e note, più uno stato come
pending,approved,rejected) - Posizione (bozza nel tuo modello di flusso, record canonico sul server, e un piccolo token di restore locale: expenseID + ultimo step)
I casi limite sono dove i flussi guadagnano o perdono fiducia. Se il manager rifiuta in Confirm, decidi se il Back ritorna a Edit (per correggere) o esce dal flusso. Se ritorna più tardi, ripristina l'ultimo step dal token salvato e ricarica la bozza. Se cambia dispositivo, tratta il server come verità: ricostruisci il path dallo stato server e mandalo allo step corretto.
Prossimi passi: documenta il tuo enum di rotte (cosa significa ogni caso e quando viene usato), aggiungi un paio di test base per la costruzione e il ripristino del path, e mantieni una regola: le viste non possiedono le decisioni di navigazione.
Se stai costruendo gli stessi tipi di flussi multi-step senza riscrivere tutto da zero, piattaforme come AppMaster (appmaster.io) applicano la stessa separazione: mantieni separate la navigazione degli step e i dati di business così le schermate possono cambiare senza rompere il progresso dell'utente.
FAQ
Usa NavigationStack con un unico NavigationPath che controlli. Aggiungi esattamente una rotta per ogni azione “Avanti” e rimuovi esattamente una rotta per ogni azione Back. Quando serve un salto (per esempio “Modifica profilo” dalla schermata di Review), taglia il path fino a uno step noto invece di aggiungere ulteriori schermate.
Perché SwiftUI ricostruisce le viste di destinazione dal valore della rotta, non dall'istanza della vista preservata. Se i dati del form vivono in @State della vista, possono azzerarsi quando la vista viene ricreata. Metti i dati di bozza in un modello condiviso (per esempio un ObservableObject) che vive al di fuori delle viste pushate.
Succede spesso quando aggiungi la stessa rotta più di una volta (spesso a causa di tap veloci o percorsi di codice multipli che scatenano la navigazione). Disabilita il pulsante Avanti mentre navighi o mentre la validazione/caricamento è in corso, e centralizza le mutazioni di navigazione in un unico punto così che venga eseguito un solo append per ogni step.
Tieni valori di routing piccoli e stabili, come un caso enum più ID leggeri. Conserva i dati mutabili (la bozza) in un oggetto condiviso separato e ricercali per ID quando necessario. Inserire modelli grandi e mutabili nel path può rompere le aspettative di Hashable e causare destinazioni non corrispondenti.
La navigazione è “dove si trova l'utente”, lo stato del flusso è “cosa ha inserito”. Tieni il path di navigazione in un router (o in uno stato top-level) e la bozza in un ObservableObject separato. Ogni schermata modifica la bozza; il router cambia solo gli step.
Tratta un deep link come istruzioni per costruire una sequenza valida di step, non come un teletrasporto a una singola vista. Costruisci il path aggiungendo prima gli step prerequisiti necessari (in base a ciò che l'utente ha già completato), poi aggiungi lo step di destinazione. Questo mantiene coerente lo stack del Back ed evita stati non validi.
Salva due cose: l'ultima rotta significativa (o un identificatore di step) e i dati della bozza. Al rilancio, ricostruisci il path usando gli stessi controlli prerequisiti che usi per i deep link, quindi carica la bozza. Se la bozza è vecchia, riavviare il flusso precompilando i campi è spesso meno sorprendente che lasciare l'utente a metà wizard.
Usa il push per il percorso principale in modo che Back possa ricostruire il flusso naturalmente. Usa sheet per attività opzionali e fullScreenCover per esperienze separate come login o cattura camera. Evita di mettere step core in modali perché i gesti di dismiss possono desincronizzare UI e stato del flusso.
Non intercettare il Back di default; lascia che si comporti come il sistema. Aggiungi una conferma solo quando uscire causerebbe la perdita di lavoro non salvato significativo, e solo quando la schermata è effettivamente “dirty”. Preferisci autosave o persistenza della bozza se ti ritrovi a dover chiedere conferme spesso.
Le cause comuni sono: nidificare più NavigationStack, ricreare NavigationPath durante gli aggiornamenti di vista e avere più proprietari che mutano il path. Mantieni uno stack per flusso, tieni il path in uno stato di lunga durata (@StateObject o un router singolo) e centralizza la logica di push/pop in un unico posto.


