Patrons NavigationStack SwiftUI pour des flux multi-étapes prévisibles
Patrons NavigationStack SwiftUI pour des flux multi-étapes, avec un routage clair, un comportement Back sûr et des exemples pratiques pour l'onboarding et les assistants d'approbation.

Ce qui déconne dans les flux multi-étapes
Un flux multi-étapes est toute séquence où l'étape 1 doit avoir eu lieu pour que l'étape 2 ait du sens. Exemples courants : onboarding, une demande d'approbation (révision, confirmation, soumission) ou un formulaire en mode assistant où l'utilisateur construit un brouillon à travers plusieurs écrans.
Ces flux semblent simples uniquement si le Retour se comporte comme attendu. Si le Retour mène quelque part de surprenant, les utilisateurs perdent confiance. Ça se traduit par des soumissions erronées, des onboardings abandonnés et des tickets support du type « Je n'arrive pas à revenir à l'écran où j'étais ».
Une navigation chaotique ressemble souvent à l'un de ces cas :
- L'app saute vers le mauvais écran, ou quitte le flux trop tôt.
- Le même écran apparaît deux fois parce qu'il a été poussé deux fois.
- Une étape se réinitialise au Back et l'utilisateur perd son brouillon.
- L'utilisateur peut atteindre l'étape 3 sans avoir complété l'étape 1, créant un état invalide.
- Après un deep link ou le redémarrage de l'app, l'écran affiché est le bon mais les données sont incorrectes.
Un modèle mental utile : un flux multi-étapes, c'est deux choses qui bougent ensemble.
D'abord, une pile d'écrans (ce que l'utilisateur peut remonter). Ensuite, un état partagé du flux (données brouillon et progression qui ne devraient pas disparaître simplement parce qu'un écran disparaît).
Beaucoup de configurations NavigationStack se cassent quand la pile d'écrans et l'état du flux divergent. Par exemple, un onboarding peut empiler « Créer le profil » deux fois (routes dupliquées), tandis que le profil brouillon vit dans la vue et est recréé au re-render. L'utilisateur appuie sur Retour, voit une version différente du formulaire et suppose que l'app est peu fiable.
Un comportement prévisible commence par nommer le flux, définir ce que doit faire le Back à chaque étape, et donner à l'état du flux une seule place claire.
Les bases de NavigationStack dont vous avez vraiment besoin
Pour les flux multi-étapes, utilisez NavigationStack plutôt que l'ancien NavigationView. NavigationView peut se comporter différemment selon les versions d'iOS et est plus difficile à raisonner quand on pousse, pop ou restaure des écrans. NavigationStack est l'API moderne qui traite la navigation comme une vraie pile.
Un NavigationStack garde un historique des écrans visités. Chaque push ajoute une destination à la pile. Chaque action retour en retire une. Cette règle simple est ce qui rend un flux stable : l'UI doit refléter une séquence claire d'étapes.
Ce que la pile contient réellement
SwiftUI ne stocke pas vos objets View. Il stocke les données que vous avez utilisées pour naviguer (votre valeur de route) et s'en sert pour reconstruire la vue de destination quand c'est nécessaire. Cela a quelques conséquences pratiques :
- Ne comptez pas sur la persistance d'une vue pour conserver des données importantes.
- Si un écran a besoin d'état, gardez-le dans un modèle (par exemple un
ObservableObject) qui vit en dehors de la vue poussée. - Si vous poussez la même destination deux fois avec des données différentes, SwiftUI les traite comme deux entrées distinctes dans la pile.
NavigationPath est ce qu'il vous faut quand votre flux n'est pas juste une ou deux pushes fixes. Pensez-y comme une liste éditable de valeurs « où l'on va ». Vous pouvez y append des routes pour avancer, retirer la dernière route pour reculer, ou remplacer tout le path pour sauter à une étape ultérieure.
C'est adapté aux assistants pas-à-pas, aux réinitialisations de flux après complétion, ou à la restauration d'un flux partiel depuis un état sauvegardé.
La prévisibilité prime sur l'astuce. Moins il y a de règles cachées (sauts automatiques, pops implicites, effets secondaires pilotés par la vue), moins vous aurez de bugs étranges liés à la pile Back.
Modéliser le flux avec un petit enum de route
La navigation prévisible commence par une décision : centralisez le routage et faites de chaque écran du flux une valeur petite et claire.
Créez une source unique de vérité, par exemple un FlowRouter (un ObservableObject) qui possède le NavigationPath. Cela garde tous les push/pop cohérents, au lieu de disperser la navigation dans les vues.
Une structure simple de router
Utilisez un enum pour représenter les étapes. Ajoutez des associated values uniquement pour des identifiants légers (IDs), pas pour des modèles entiers.
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() }
}
Séparer l'état du flux de l'état de navigation
Considérez la navigation comme « où est l'utilisateur » et l'état du flux comme « ce qu'il a saisi jusqu'ici ». Mettez les données du flux dans leur propre store (par exemple OnboardingState avec nom, email, documents uploadés) et gardez-les stables pendant que les écrans vont et viennent.
Une règle simple :
FlowRouter.pathcontient seulement des valeursStep.OnboardingStatecontient les saisies et les données brouillon de l'utilisateur.- Les étapes transportent des IDs pour retrouver les données, pas les données elles-mêmes.
Ainsi vous évitez le hashing fragile, des paths trop volumineux, et des réinitialisations surprises quand SwiftUI reconstruit des vues.
Étape par étape : construire un assistant avec NavigationPath
Pour des écrans en mode assistant, la méthode la plus simple est de contrôler vous-même la pile. Visez une source unique de vérité pour « où j'en suis ? » et une seule façon d'avancer ou de reculer.
Commencez avec un NavigationStack(path:) lié à un NavigationPath. Chaque écran poussé est représenté par une valeur (souvent un case d'enum), et vous enregistrez les destinations une seule fois.
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
}
}
Pour que le Retour reste prévisible, adoptez quelques habitudes : append exactement une route pour avancer, gardez « Suivant » linéaire (n'empilez que l'étape suivante), et quand vous devez sauter en arrière (comme « Éditer le profil » depuis Revue), taillez la pile jusqu'à un index connu.
Cela évite les écrans dupliqués accidentels et fait que le Retour correspond à ce que les utilisateurs attendent : une touche = une étape.
Garder les données stables pendant que les écrans vont et viennent
Un flux multi-étapes paraît peu fiable quand chaque écran possède son propre état. Vous tapez un nom, avancez, revenez, et le champ est vide parce que la vue a été recréée.
La correction est simple : traitez le flux comme un unique objet brouillon, et laissez chaque étape l'éditer.
En SwiftUI, cela signifie généralement un ObservableObject partagé créé au début du flux et passé à chaque étape. Ne stockez pas les valeurs brouillon dans @State de chaque vue sauf si elles appartiennent vraiment à cet écran uniquement.
final class OnboardingDraft: ObservableObject {
@Published var fullName = ""
@Published var email = ""
@Published var wantsNotifications = false
var canGoNextFromProfile: Bool {
!fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& email.contains("@")
}
}
Créez-le au point d'entrée, puis partagez-le avec @StateObject et @EnvironmentObject (ou passez-le explicitement). Ainsi la pile peut changer sans perte de données.
Décider ce qui survit au retour en arrière
Tout ne doit pas persister indéfiniment. Fixez vos règles à l'avance pour que le flux reste cohérent.
Conservez les saisies utilisateur (champs texte, toggles, sélections) sauf si elles sont explicitement réinitialisées. Réinitialisez l'état UI spécifique à une étape (spinners de chargement, alertes temporaires, animations courtes). Effacez les champs sensibles (codes à usage unique) quand on quitte l'étape correspondante. Si un choix impacte des étapes ultérieures, ne nettoyez que les champs dépendants.
La validation s'intègre naturellement ici. Plutôt que de laisser l'utilisateur avancer puis afficher une erreur sur l'écran suivant, gardez-le sur l'étape courante tant qu'elle n'est pas valide. Désactiver le bouton sur la base d'une propriété calculée comme canGoNextFromProfile suffit souvent.
Sauvegarder des checkpoints sans en faire trop
Certains brouillons peuvent vivre seulement en mémoire. D'autres devraient survivre à un redémarrage ou à un crash. Un défaut pratique :
- Gardez les données en mémoire tant que l'utilisateur avance activement dans les étapes.
- Persistez localement à des jalons clairs (compte créé, approbation soumise, paiement démarré).
- Persistez plus tôt si le flux est long ou si la saisie prend plus d'une minute.
De cette façon, les écrans peuvent aller et venir librement, et la progression de l'utilisateur reste stable et respectueuse de son temps.
Deep links et restauration d'un flux partiellement terminé
Les deep links comptent parce que les flux réels commencent rarement à l'étape 1. Quelqu'un clique un email, une notification push ou un lien partagé et s'attend à atterrir sur le bon écran, par exemple l'étape 3 d'un onboarding ou l'écran final d'approbation.
Avec NavigationStack, traitez un deep link comme des instructions pour bâtir un path valide, pas comme une commande pour sauter vers une vue. Commencez au début du flux et n'append que les étapes qui sont valides pour cet utilisateur et cette session.
Transformer un lien externe en une séquence de routes sûre
Un bon pattern : parsez l'ID externe, chargez les données minimales nécessaires, puis convertissez ça en une séquence de routes.
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
}
Ces vérifications sont vos garde-fous. Si des prérequis manquent, ne posez pas l'utilisateur sur l'étape 3 avec une erreur et sans moyen d'avancer. Envoyez-le à la première étape manquante, et assurez-vous que la pile Back raconte toujours une histoire cohérente.
Restaurer un flux partiel
Pour restaurer après relance, sauvegardez deux choses : le dernier état de route connu et les données brouillon saisies. Puis décidez comment reprendre sans surprendre les gens.
Si le brouillon est récent (minutes ou heures), proposez une option claire « Reprendre ». S'il est ancien, recommencez depuis le début mais utilisez le brouillon pour préremplir les champs. Si des exigences ont changé, reconstruisez le path en appliquant les mêmes garde-fous.
Push vs modal : garder le flux facile à quitter
Un flux est prévisible quand il y a une seule façon principale d'avancer : empiler des écrans sur une pile unique. Utilisez les sheets et full-screen covers pour des tâches secondaires, pas pour le chemin principal.
Le push (NavigationStack) convient quand l'utilisateur s'attend à ce que le Retour retrace ses pas. Les modales conviennent pour des tâches annexes rapides ou une confirmation risquée.
Quelques règles simples évitent la plupart des bizarreries :
- Push pour le chemin principal (Étape 1, Étape 2, Étape 3).
- Utilisez une sheet pour des tâches optionnelles (choisir une date, sélectionner un pays, scanner un document).
- Utilisez
fullScreenCoverpour des « mondes séparés » (connexion, capture caméra, un long document légal). - Utilisez une modale pour les confirmations (annuler le flux, supprimer un brouillon, soumettre pour approbation).
L'erreur commune est de placer des écrans principaux dans des sheets. Si l'étape 2 est une sheet, l'utilisateur peut la balayer pour la fermer, perdre le contexte, et se retrouver avec une pile qui indique qu'il est à l'étape 1 alors que ses données disent qu'il a fini l'étape 2.
Les confirmations sont l'inverse : empiler un écran « Êtes-vous sûr ? » dans le wizard encombre la pile et peut créer des boucles (Étape 3 -> Confirmer -> Retour -> Étape 3 -> Retour -> Confirmer).
Comment tout fermer proprement après « Terminé »
Décidez d'abord ce que signifie « Terminé » : retour à l'écran d'accueil, retour à la liste, ou affichage d'un écran de succès.
Si le flux est poussé, réinitialisez votre NavigationPath à vide pour revenir au début. Si le flux est présenté modally, appelez dismiss() depuis l'environnement. Si vous avez les deux (une modale contenant un NavigationStack), fermez la modale, pas chaque écran empilé. Après une soumission réussie, videz aussi l'état du brouillon pour qu'un flux rouvert recommence à neuf.
Comportement du bouton Retour et moments « Êtes-vous sûr ? »
Pour la plupart des flux multi-étapes, la meilleure stratégie est de ne rien faire : laissez le bouton système Retour (et le geste swipe-back) fonctionner. Cela correspond aux attentes utilisateur et évite les bugs où l'UI indique une chose alors que l'état de navigation en indique une autre.
Intercepter n'en vaut la peine que lorsqu'un retour causerait un vrai dommage, comme la perte d'un long formulaire non sauvegardé ou l'abandon d'une action irréversible. Si l'utilisateur peut revenir en toute sécurité et continuer, n'ajoutez pas de friction.
Une approche pratique : conservez la navigation système, mais affichez une confirmation uniquement quand l'écran est « dirty » (édité). Cela implique de fournir votre propre action de retour et de poser la question une seule fois, avec une voie de sortie claire.
@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) {}
}
}
Empêchez que cela devienne un piège :
- Demandez seulement si vous pouvez expliquer la conséquence en une courte phrase.
- Offrez une option sûre (Annuler, Continuer l'édition) plus une sortie claire (Supprimer, Quitter).
- Ne cachez pas les boutons Back sauf si vous les remplacez par un Back ou Close évident.
- Préférez confirmer l'action irréversible (comme « Approuver ») plutôt que de bloquer la navigation partout.
Si vous vous retrouvez à combattre souvent le geste de retour, c'est généralement le signe que le flux a besoin d'autosave, d'un brouillon sauvegardé, ou d'étapes plus petites.
Erreurs courantes qui créent des piles Back bizarres
La plupart des bugs « pourquoi ça revient là ? » ne sont pas le fruit d'un SwiftUI aléatoire. Ils proviennent de patterns qui rendent l'état de navigation instable. Pour un comportement prévisible, traitez la pile Back comme des données d'app : stable, testable et possédée en un seul endroit.
Piles supplémentaires accidentelles
Un piège fréquent est de se retrouver avec plus d'un NavigationStack sans s'en rendre compte. Par exemple, chaque onglet a sa propre stack racine, et une vue enfant ajoute une autre stack dans le flux. Le résultat : des comportements de retour confus, des barres de navigation manquantes, ou des écrans qui ne pop pas comme prévu.
Un autre souci fréquent est de recréer votre NavigationPath trop souvent. Si le path est créé dans une vue qui se re-render, il peut se réinitialiser lors de changements d'état et renvoyer l'utilisateur à l'étape 1 après qu'il ait tapé dans un champ.
Les fautes derrière la plupart des piles bizarres sont simples :
- Imbriquer
NavigationStackdans une autre stack (souvent dans des tabs ou le contenu d'une sheet). - Réinitialiser
NavigationPath()pendant des mises à jour de vue au lieu de le garder dans un état durable. - Mettre des valeurs non-stables dans votre route (comme un objet modèle qui change), ce qui casse le
Hashableet cause des destinations dépareillées. - Disperser les décisions de navigation dans plusieurs handlers de boutons jusqu'à ce que personne ne puisse expliquer ce que signifie « suivant ».
- Piloter le flux depuis plusieurs sources à la fois (par exemple à la fois un view model et une vue mutent le path).
Si vous devez passer des données entre étapes, préférez des identifiants stables dans la route (IDs, enums d'étape) et stockez les vraies données du formulaire dans un état partagé.
Un exemple concret : si votre route est .profile(User) et que User change pendant la saisie, SwiftUI peut le traiter comme une route différente et réorganiser la pile. Faites la route .profile et stockez le profil brouillon dans un état partagé.
Checklist rapide pour une navigation prévisible
Quand un flux semble décalé, c'est souvent parce que la pile Back n'exprime pas la même histoire que l'utilisateur. Avant de peaufiner l'UI, passez en revue vos règles de navigation.
Testez sur un appareil réel, pas seulement en preview, et essayez des taps lents et rapides. Les taps rapides révèlent souvent des pushes dupliqués et des états manquants.
- Revenez d'une étape à la fois depuis l'écran final jusqu'au premier. Confirmez que chaque écran affiche les mêmes données que l'utilisateur a saisies précédemment.
- Déclenchez Annuler depuis chaque étape (y compris la première et la dernière). Vérifiez que ça revient toujours à un endroit sensé, pas à un écran aléatoire antérieur.
- Force quit en plein flux et relancez. Assurez-vous de pouvoir reprendre en toute sécurité, soit en restaurant le path, soit en redémarrant à une étape connue avec des données sauvegardées.
- Ouvrez le flux via un deep link ou un raccourci d'app. Vérifiez que l'étape cible est valide ; si des données requises manquent, redirigez vers la plus tôt étape capable de les collecter.
- Terminez avec Terminé et confirmez que le flux est correctement supprimé. L'utilisateur ne devrait pas pouvoir appuyer sur Retour et ré-entrer un assistant complété.
Un moyen simple de tester : imaginez un wizard onboarding à trois écrans (Profil, Permissions, Confirmer). Entrez un nom, avancez, revenez, éditez, puis sautez à Confirmer via un deep link. Si Confirmer affiche l'ancien nom, ou si Retour vous mène à un écran Profil dupliqué, vos mises à jour du path ne sont pas cohérentes.
Si vous passez la checklist sans surprises, votre flux paraîtra calme et prévisible, même quand les utilisateurs partent et reviennent plus tard.
Un exemple réaliste et les prochaines étapes
Imaginez un flux d'approbation manager pour une demande de frais. Il a quatre étapes : Review, Edit, Confirm et Receipt. L'utilisateur attend une chose : Retour doit toujours aller à l'étape précédente, pas à un écran aléatoire visité plus tôt.
Un enum de route simple garde ça prévisible. Votre NavigationPath doit stocker uniquement la route et de petits identifiants nécessaires pour recharger l'état, comme un expenseID et un mode (review vs edit). Évitez d'empiler des modèles volumineux et mutables dans le path car cela rend les restores et les deep links fragiles.
Gardez le brouillon de travail dans une source de vérité unique en dehors des vues, par exemple un @StateObject flow model (ou un store). Chaque étape lit et écrit ce modèle, donc les écrans peuvent apparaître et disparaître sans perte de saisies.
Au minimum, vous suivez trois choses :
- Routes (par exemple :
review(expenseID),edit(expenseID),confirm(expenseID),receipt(expenseID)) - Données (un objet brouillon avec lignes et notes, plus un état comme
pending,approved,rejected) - Localisation (brouillon dans votre modèle de flux, enregistrement canonique sur le serveur, et un petit token de restauration local : expenseID + dernière étape)
Les cas limites sont ceux où les flux gagnent ou perdent la confiance. Si le manager rejette dans Confirm, décidez si Retour renvoie à Edit (pour corriger) ou quitte le flux. S'il revient plus tard, restaurez la dernière étape depuis le token sauvegardé et rechargez le brouillon. S'il change d'appareil, traitez le serveur comme la vérité : reconstruisez le path depuis le statut serveur et envoyez-le à l'étape appropriée.
Étapes suivantes : documentez votre enum de route (ce que signifie chaque case et quand il est utilisé), ajoutez quelques tests basiques pour la construction et la restauration du path, et respectez une règle : les vues ne possèdent pas les décisions de navigation.
Si vous construisez le même type de flux multi-étapes sans tout réécrire, des plateformes comme AppMaster (appmaster.io) appliquent la même séparation : gardez la navigation des étapes et les données métier séparées pour que les écrans puissent changer sans casser la progression utilisateur.
FAQ
Utilisez NavigationStack avec un seul NavigationPath que vous contrôlez. Ajoutez exactement une route par action « Suivant » et supprimez exactement une route par action Retour. Quand vous devez sauter (par exemple « Éditer le profil » depuis Revue), taillez le chemin jusqu'à un index connu au lieu d'empiler des écrans supplémentaires.
SwiftUI reconstruit les vues de destination à partir de la valeur de route, pas d'une instance de vue préservée. Si vos données de formulaire vivent dans @State d'une vue, elles peuvent être réinitialisées quand la vue est recréée. Placez les données brouillon dans un modèle partagé (par exemple un ObservableObject) vivant en dehors des vues poussées.
C'est généralement dû à l'ajout de la même route plusieurs fois (souvent à cause de taps rapides ou de chemins de code multiples déclenchant la navigation). Désactivez le bouton Suivant pendant la navigation ou pendant la validation/le chargement, et centralisez les mutations de navigation pour qu'il n'y ait qu'un seul append par étape.
Gardez les valeurs de routage petites et stables, comme un cas d'enum et des IDs légers. Conservez les données mutables (le brouillon) dans un objet partagé séparé et lookup via l'ID si nécessaire. Empiler des modèles volumineux et changeants dans le path peut casser les attentes Hashable et conduire à des destinations incohérentes.
La navigation décrit « où est l'utilisateur », et l'état du flux décrit « ce qu'il a saisi ». Possédez le chemin de navigation dans un router (ou un état top-level) et possédez le brouillon dans un ObservableObject séparé. Chaque écran modifie le brouillon ; le router ne change que les étapes.
Considérez un deep link comme des instructions pour construire une séquence de routes valides, pas comme un téléport vers une vue. Construisez le path en ajoutant d'abord les étapes prérequises (selon ce que l'utilisateur a déjà complété), puis ajoutez l'étape cible. Ainsi la pile Retour reste cohérente et vous évitez un état invalide.
Sauvegardez deux choses : l'identifiant de la dernière étape significative (ou un token de step) et les données brouillon. Au relancement, reconstruisez le path en appliquant les mêmes vérifications prérequises que pour les deep links, puis rechargez le brouillon. Si le brouillon est ancien, redémarrer le flux en préremplissant les champs est souvent moins surprenant que de laisser l'utilisateur en plein milieu du wizard.
Utilisez le push pour le chemin principal afin que le bouton Retour refasse naturellement le parcours. Servez-vous de sheets pour des tâches optionnelles et de fullScreenCover pour des expériences séparées comme la connexion ou la capture caméra. Évitez de mettre des étapes principales dans des modales car les gestes de fermeture peuvent désynchroniser l'UI et l'état du flux.
Ne surchargez pas le Retour par défaut ; laissez le comportement système faire son travail. N'ajoutez une confirmation que lorsque quitter ferait perdre un travail non sauvegardé significatif, et seulement si l'écran est réellement « dirty ». Préférez l'autosave ou la persistance de brouillon si vous êtes souvent tenté d'intercepter le Retour.
Les causes courantes sont : imbriquer plusieurs NavigationStack, recréer NavigationPath lors de mises à jour de vue, et avoir plusieurs propriétaires qui mutent le path. Gardez une stack par flux, conservez le path dans un état long-lived (@StateObject ou un router central), et centralisez toute la logique push/pop en un seul endroit.


