Routing flow
How a tap becomes a navigation push, the relationship between Route, Router, and RootView, and how generators wire screens.
Generated projects use a single NavigationStack driven by an @Observable Router. Every destination is a case in one Route enum. Every screen has one arm in one switch statement.
The pieces
Section titled “The pieces”Route: aHashableenum, one case per destination.Router: an@Observableclass wrapping aNavigationPath.RootView: aNavigationStackbound torouter.pathwith anavigationDestination(for: Route.self)that switches on the route.
How navigation happens
Section titled “How navigation happens”flowchart LR
V[View] -->|tap| VM[ViewModel]
VM -->|"router.push(.movieDetail)"| R[Router]
R -->|"path.append(route)"| NS[NavigationStack]
NS -->|"switch route"| RV[RootView]
RV -->|"MovieDetailView()"| Next[New screen]
- A view sends a tap to its ViewModel.
- The ViewModel calls
router.push(.movieDetail). - The router appends the route to its
NavigationPath. - SwiftUI’s
NavigationStacknotices the path changed and asks thenavigationDestinationmodifier to resolve the new route. - The
switchinRootViewreturns the matching view.
The whole flow stays type-safe: routes are an enum, so adding or removing a destination is a compile-time event.
The pieces in code
Section titled “The pieces in code”enum Route: Hashable { // MARK: - Cases (auto-generated) case home case movieList case movieDetail // MARK: - End auto-generated
case settings(tab: SettingsTab)}CLI-managed cases live between markers. Cases with associated values (parameters) go below, hand-written.
Router
Section titled “Router”@Observablefinal class Router { var path = NavigationPath()
func push(_ route: Route) { path.append(route) } func pop() { if !path.isEmpty { path.removeLast() } } func popToRoot() { path = NavigationPath() } func replace(_ route: Route) { path = NavigationPath() path.append(route) }}Tiny on purpose. NavigationPath does the heavy lifting; Router just exposes the operations ViewModels need.
RootView
Section titled “RootView”struct RootView: View { @State private var router = Router() var body: some View { NavigationStack(path: $router.path) { HelloView() .navigationDestination(for: Route.self) { route in switch route { // MARK: - Routes (auto-generated) case .home: HomeView() case .movieList: MovieListView() case .movieDetail: MovieDetailView() // MARK: - End auto-generated
case .settings(let tab): SettingsView(tab: tab) } } } .environment(router) }}The root view binds router.path to NavigationStack and dispatches each route to its view. Children read the router from the environment:
@Environment(Router.self) private var routerPushing from a ViewModel
Section titled “Pushing from a ViewModel”ViewModels never import SwiftUI. They take a Router reference and call push:
@MainActor @Observablefinal class MovieListViewModel { @Injected(\.movieService) private var service private let router: Router
init(router: Router) { self.router = router }
func didTapMovie(_ id: Int) { router.push(.movieDetail) // To pass parameters, use a parameterized route: // router.push(.movieDetail(id: id)) }}For the parameterized form, edit Route.swift to make the case carry the value, and update the matching switch arm in RootView.swift:
case .movieDetail(let id): MovieDetailView(id: id)generate route movieDetail adds the bare case; you fill in the parameter.
How generators wire routes
Section titled “How generators wire routes”generate screen MovieList on Movies --uses MovieService does three things related to routing:
- Add a case to
Route.swift:case movieListbetween the markers. - Add an arm to
RootView.swift:case .movieList: MovieListView()between the markers. - Skip if a marker is missing: prints the snippet to paste in.
--no-route skips step 1 and 2. Use it when you’ll add the route by hand (parameterized cases) or when the screen is reachable via a sheet or tab instead.
Deep links and tab roots
Section titled “Deep links and tab roots”Router.replace(_:) clears the stack and pushes one route. Use it for tab changes or deep-link entry:
router.replace(.movieList)For tab-rooted apps, hold one Router per tab. Generated projects ship with a single Router; if you adopt a tab structure, store one router per tab and pass them down as environment values.