Skip to content

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.

  • Route: a Hashable enum, one case per destination.
  • Router: an @Observable class wrapping a NavigationPath.
  • RootView: a NavigationStack bound to router.path with a navigationDestination(for: Route.self) that switches on the route.
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]
  1. A view sends a tap to its ViewModel.
  2. The ViewModel calls router.push(.movieDetail).
  3. The router appends the route to its NavigationPath.
  4. SwiftUI’s NavigationStack notices the path changed and asks the navigationDestination modifier to resolve the new route.
  5. The switch in RootView returns the matching view.

The whole flow stays type-safe: routes are an enum, so adding or removing a destination is a compile-time event.

Infrastructure/Routing/Route.swift
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.

Infrastructure/Routing/Router.swift
@Observable
final 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.

App/RootView.swift
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 router

ViewModels never import SwiftUI. They take a Router reference and call push:

Features/Movies/MovieList/MovieListViewModel.swift
@MainActor @Observable
final 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.

generate screen MovieList on Movies --uses MovieService does three things related to routing:

  1. Add a case to Route.swift: case movieList between the markers.
  2. Add an arm to RootView.swift: case .movieList: MovieListView() between the markers.
  3. 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.

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.