Fiche 06.06 — Router / Coordinator : navigation centralisée
Objectif
Sortir la navigation des vues et la centraliser dans un Router @Observable qui détient le path: [Route]. Tu apprends à naviguer depuis un ViewModel sans qu'il importe SwiftUI (via un protocole), à gérer le deep linking et le multi-stacks (un Router par onglet). C'est l'équivalent moderne du Coordinator pattern UIKit.
1. La brique : un enum Route + un Router
Route est un enum Hashable qui décrit toutes les destinations pushables. Les associated values portent les paramètres.
Le Router détient le path et expose une API impérative. @Observable → SwiftUI ré-observe path automatiquement.
Réflexe UIKit → moderne : navigationController.pushViewController(_:animated:) devient router.push(.productDetail(id: 42)). popToRootViewController devient popToRoot(). Le path est ta viewControllers stack, mais en valeur (data-driven), pas en référence.
2. Câbler le Router à NavigationStack
Un seul navigationDestination(for:) mappe Route → View pour toute la stack. Le Router est injecté dans l'environment pour être atteignable depuis n'importe quelle vue enfant.
Depuis une vue enfant, tu récupères le Router et tu pushes :
3. Naviguer depuis un ViewModel (sans SwiftUI)
Le ViewModel ne doit pas importer SwiftUI ni connaître Route concrètement plus que nécessaire. On lui injecte un protocole de navigation → testable et découplé.
Le ViewModel dépend de l'abstraction :
Et la vue injecte le vrai Router :
En test, tu passes un spy au lieu du vrai Router :
Réflexe UIKit → moderne : avant, ton ViewController (ou Presenter) appelait coordinator?.showDetail(id:). Ici c'est identique en intention : le VM parle à une abstraction, pas à l'UI.
4. Deep linking : URL → [Route] → path
Le pattern propre : une fonction pure qui parse l'URL en tableau de routes, puis on assigne ce tableau au path (la stack s'affiche d'un coup).
Branchement à l'app — .onOpenURL doit voir le router, donc on le pose dans RootView (là où le @State est en scope), pas par-dessus :
Pas idéal : pousser les routes une par une avec push dans une boucle (animations qui s'empilent, états transitoires). Préférable : construire [Route] puis affecter path en une fois.
5. Multi-stacks : un Router par onglet
Chaque onglet de TabView a sa propre NavigationStack et donc son propre Router. On regroupe les routers dans un objet d'app pour piloter l'onglet sélectionné et les resets.
Comportement standard iOS : re-tap sur l'onglet déjà actif → popToRoot de cet onglet. On l'intercepte avec un Binding custom sur la sélection (un .onChange(of:) ne suffit pas : il ne se déclenche pas quand newTab == oldTab).
Réflexe UIKit → moderne : c'est exactement ce que faisait un UITabBarController avec un UINavigationController (et son Coordinator) par onglet. Chaque Coordinator gérait son pushViewController. Ici : un Router par stack.
En UIKit (rappel)
Le Coordinator pattern vient d'UIKit. Un Coordinator détient le UINavigationController, expose start(), gère ses childCoordinators et décide des push/present. Les ViewControllers ne se connaissent pas : ils délèguent la navigation au Coordinator (via closure ou protocole).
Multi-stacks : un UITabBarController avec un UINavigationController (et son Coordinator) par onglet — exactement ce que la section 5 reproduit avec un Router par onglet.
Différence clé de mentalité : en UIKit, le Coordinator pilote un UINavigationController (référence-driven) et exécute des pushViewController impératifs. En SwiftUI, le Router détient un path: [Route] observable (data-driven) ; tu mutes la donnée, et NavigationStack reconstruit la pile.
👉 En SwiftUI : coordinator.pushViewController(detailVC) devient router.path.append(.productDetail(id:)), et le Coordinator devient un Router @Observable.
Points à connaître
@Environment(Router.self)exige.environment(router)en amont. Si tu oublies l'injection, crash à l'exécution (objet@Observablenon trouvé). C'est silencieux à la compilation.Routedoit êtreHashableET les associated values aussi. Uncaseavec une valeur non-Hashable(ex. une closure) casse la conformance et empêcheNavigationStack(path:).@Bindablepour binder un@Observable:$router.pathne marche que sur une propriété@Stateou via@Bindable var router = router. Sur un objet venant de l'environment, fais@Bindable var router = routerdans lebody.- Le ViewModel ne doit jamais importer SwiftUI. Si ton protocole de navigation référence des types
View, tu as raté le découplage. Garde-le surRoute(data) uniquement.
Exercice (10-20 min)
Crée un mini-flow e-commerce :
- Un
enum Route: Hashableavec 3 cas :.list,.detail(productID: Int),.checkout. - Un
@Observable Routeravecpath,push,pop,popToRoot, et unRouterProtocol(push/pop). - Un
CartViewModel@MainActor @Observablequi reçoit unRouterProtocoleninitet exposefunc checkout()appelantrouter.push(.checkout). Il ne doit pas importer SwiftUI. - Une
RootViewavecNavigationStack(path:)+ un seulnavigationDestination(for: Route.self). - Bonus : écris un
RouterSpyet un test (#expect) vérifiant quecheckout()pushe bien.checkout.
Critère de réussite : tu navigues depuis le VM, et le test passe sans aucun import SwiftUI dans le VM ni dans le test.
Question d'entretien
Q1 — « Comment navigues-tu depuis un ViewModel en SwiftUI ? »
Le ViewModel ne connaît pas SwiftUI. Je lui injecte un protocole de navigation (
RouterProtocol) en init. Le ViewModel appellerouter.push(.detail(id:)); la vue, elle, fournit l'implémentation concrète (unRouter@Observablequi piloteNavigationStack(path:)). Avantage : le VM reste testable — en test j'injecte un spy qui enregistre les routes poussées.
Q2 — « C'est quoi un Coordinator, et son équivalent en SwiftUI ? »
En UIKit, le Coordinator est un objet qui centralise la logique de navigation et pilote le
UINavigationController(pushViewController), pour sortir cette responsabilité des ViewControllers. En SwiftUI moderne, l'équivalent est unRouter@Observablequi détient unpath: [Route];Routeest un enum décrivant les destinations, etnavigationDestination(for:)fait le mapping route → vue. Même intention (navigation découplée, testable), mais data-driven au lieu de référence-driven.
Q3 — « Comment gères-tu un deep link vers un écran profond ? »
Je parse l'
URLen[Route]via une fonction pure, puis j'affecte ce tableau aupathdu Router en une seule fois (router.path = routes). NavigationStack reconstruit toute la pile d'un coup, sans empiler les animations. Je branche ça sur.onOpenURL.
Résumé
Router@Observabledétientpath: [Route]et exposepush/pop/popToRoot/reset.Route= enumHashable(associated values) ; un seulnavigationDestination(for:)mappe route → vue.- Navigation depuis le VM via un
RouterProtocolinjecté → découplé et testable (spy). - Deep link :
URL→[Route](fonction pure) → affecterpathen une fois. - Multi-stacks : un
Routerpar onglet deTabView; re-tap onglet actif →popToRoot. - Équivalent moderne et data-driven du Coordinator UIKit (qui pilotait
UINavigationController).