There’s a moment every iOS app hits where navigation stops being a UI concern and starts being an architecture problem.
You have a collection grid that needs to open an album detail. The detail needs to open a sheet. The sheet needs to dismiss itself and trigger a tab switch. A widget tap needs to land the user on a specific crate three levels deep. And somewhere in the middle of wiring all this together, you realize your views are intimately acquainted with each other’s internal structure — and that’s going to hurt later. The tangled wires always do.
That’s where the coordinator pattern comes in. Not because it’s fashionable, and not because you read it in a WWDC session. Because it solves a real problem: views shouldn’t know where they’re going. They should just say go here and let something else figure out what that means.
What the Pattern Actually Does
The core idea is straightforward. Instead of NavigationLink(destination: AlbumDetailView(release: release)) scattered across your view hierarchy, you have a single object that owns all navigation decisions. Views call into it. It pushes routes onto stacks, presents sheets, switches tabs. Views stay ignorant of the mechanics.
The benefits compound as the app grows. Deep links become trivial — they’re just coordinator calls. Testing navigation logic doesn’t require mounting views. Refactoring a destination view doesn’t require hunting down every NavigationLink that pointed at it.
The tradeoff is real too. You’re adding a layer of indirection. For a three-screen app, that’s probably overkill. For VinylCrate — which has five tabs, multiple overlapping navigation stacks, sheet presentations, and deep link handling from both URL schemes and universal links — it paid for itself early.
The Route Enum
Everything starts here. Every possible destination in the app is a case.
enum Route: Hashable, Identifiable {
// Push navigation
case albumDetail(ReleaseDetail, Crate?)
case crateDetail(Crate, ViewModelContainer)
case wantsCrateDetail(ViewModelContainer)
case folderReleases(CollectionFolder, CollectionManager)
// Sheet presentation
case profile
case scanner
case crateEditor(CustomCrate?)
case collectionManagement(CollectionManager)
case crateInsights(String, [ReleaseDetail])
case rarityDetail([ReleaseDetail], RarityTier)
case marketplaceWebView(URL, String)
}
Two things worth noting here. First, routes carry their data. albumDetail holds the ReleaseDetail directly. No fetching at the destination, no ambient state lookup, no optional unwrapping of something that should always be there. The destination gets exactly what it needs, passed at the moment navigation is initiated.
Second, the enum itself knows whether it’s a push or a sheet:
var isSheet: Bool {
switch self {
case .albumDetail, .crateDetail, .folderReleases, .wantsCrateDetail:
return false
case .collectionManagement, .crateEditor, .crateInsights,
.marketplaceWebView, .profile, .rarityDetail, .scanner:
return true
}
}
This is where the design gets opinionated. The distinction between “push this view” and “present this sheet” lives in the route definition, not scattered across individual call sites. If the UX decision changes — say, album detail should now be a sheet on some device class — you change it in one place.
The Coordinator
AppCoordinator is @Observable and @MainActor. It owns a NavigationState that holds per-tab NavigationPath values, plus activeSheet for sheet presentation.
@MainActor
@Observable
final class AppCoordinator {
private(set) var navigationState: NavigationState
var activeSheet: Route?
var deepLinkError: String?
func navigate(to route: Route, on tab: NavigationTab? = nil) {
if route.isSheet {
presentSheet(route)
return
}
let targetTab = tab ?? navigationState.selectedTab
if targetTab != navigationState.selectedTab {
navigationState.selectedTab = targetTab
}
var path = navigationState.path(for: targetTab)
path.append(route)
navigationState.setPath(path, for: targetTab)
}
func presentSheet(_ route: Route) {
activeSheet = route
}
func dismissSheet() {
activeSheet = nil
}
func popToRoot(on tab: NavigationTab? = nil) {
let targetTab = tab ?? navigationState.selectedTab
navigationState.popToRoot(on: targetTab)
}
func switchTab(to tab: NavigationTab, clearStacks: Bool = true) {
let previousTab = navigationState.selectedTab
if clearStacks && previousTab != tab {
navigationState.popToRoot(on: previousTab)
navigationState.popToRoot(on: tab)
}
navigationState.selectedTab = tab
}
}
The navigate function is the main entry point. It checks isSheet, routes accordingly, and handles tab switching if a specific tab is requested. The tab parameter is optional by design — when views call this, they usually don’t care which tab they’re on. Deep links do care, and they pass the tab explicitly.
NavigationState is a separate @Observable class that holds the actual path state:
@MainActor
@Observable
final class NavigationState {
var selectedTab: NavigationTab = .home
var homePath = NavigationPath()
var cratesPath = NavigationPath()
var discoverPath = NavigationPath()
var marketplacePath = NavigationPath()
var searchPath = NavigationPath()
}
Per-tab paths are the key to making tab bar navigation work correctly. Each tab has its own independent stack. Switching tabs doesn’t blow away where you were. Tab-bar apps need this, and NavigationPath makes it tractable.
Wiring It Into the View Hierarchy
The coordinator is injected at the root via a custom EnvironmentKey:
private struct CoordinatorKey: EnvironmentKey {
static let defaultValue: AppCoordinator? = nil
}
extension EnvironmentValues {
var coordinator: AppCoordinator? {
get { self[CoordinatorKey.self] }
set { self[CoordinatorKey.self] = newValue }
}
}
extension View {
func withCoordinator(_ coordinator: AppCoordinator) -> some View {
environment(\.coordinator, coordinator)
}
}
In VinylCrateApp, the coordinator is created once and pushed down:
var body: some Scene {
WindowGroup {
RootView()
.withCoordinator(coordinator)
.onOpenURL { url in
Task { await coordinator.handle(url: url) }
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
Task { await coordinator.handle(userActivity: activity) }
}
}
.modelContainer(modelContainer)
}
Deep link entry points are right there at the scene level — the cleanest possible place for them.
In AdaptiveRootView, each tab’s NavigationStack binds directly to the coordinator’s path state:
NavigationStack(path: Binding(
get: { coordinator?.path(for: .home) ?? NavigationPath() },
set: { newPath in
coordinator?.navigationState.setPath(newPath, for: .home)
}
)) {
CollectionView(container: container, onScanTapped: {
coordinator?.presentSheet(.scanner)
})
.navigationDestination(for: Route.self) { route in
destinationView(for: route)
}
}
The navigationDestination registration happens once per tab. Every route that results in a push goes through that single switch. No per-view destination registrations scattered around.
Sheet presentation is driven by activeSheet in AdaptiveRootView:
.sheet(item: Binding(
get: { coordinator?.activeSheet },
set: { if $0 == nil { coordinator?.dismissSheet() } }
)) { route in
SheetFactory.makeSheet(
for: route,
container: container,
oauthService: oauthService,
coordinator: coordinator
)
}
SheetFactory is worth calling out. It’s a pure enum with a @ViewBuilder method — no state, no lifecycle, just a switch over sheet routes to the appropriate view. All that sheet-building logic is extracted from AdaptiveRootView, which would otherwise become a 600-line monster.
Views Just Say Where to Go
The payoff is at the call site. Here’s what a grid item looks like:
struct CollectionGridItem: View {
@Environment(\.coordinator) private var coordinator
var body: some View {
Button {
coordinator?.navigate(to: .albumDetail(item, crateContext))
} label: {
// ... album artwork, title, etc.
}
}
}
That’s it. The grid item doesn’t import anything about AlbumDetailView. It doesn’t know whether this opens a push or a sheet (it’s a push, but the grid item doesn’t need to know that). It doesn’t know which tab it’s on. It just names a destination and hands control to the coordinator.
Same story across the app:
// CratesListView
coordinator?.navigate(to: .crateDetail(crate, container))
coordinator?.navigate(to: .wantsCrateDetail(container))
// DiscoverView
coordinator?.navigate(to: .albumDetail(release, nil))
// Anywhere a scanner sheet is needed
coordinator?.presentSheet(.scanner)
Consistent. Searchable. Easy to audit.
Wherever I May Roam
This is where the two-layer approach — DeepLink enum plus Route enum — earns its keep.
DeepLink represents what arrives from the outside world: a URL or user activity. It’s intentionally thin. Just the incoming identifier:
enum DeepLink: Equatable {
case album(id: Int)
case crate(id: UUID)
case search(query: String? = nil, barcode: String? = nil)
case scanner
}
The coordinator’s handle(url:) method parses the URL, produces a DeepLink, then resolves it to a Route — potentially doing async work like fetching a ReleaseDetail from Discogs before navigating:
private func handleAlbumDeepLink(id: Int) async {
guard let client = discogsClient else {
showDeepLinkError(message: "Unable to load album")
return
}
do {
let release = try await client.fetch(.release(id: id, currencyAbbr: nil), as: ReleaseDetail.self)
navigate(to: .albumDetail(release, nil), on: .home)
} catch {
showDeepLinkError(message: "Album not found")
}
}
The separation matters here. DeepLink cases are URL-shaped — they carry IDs and query params. Route cases are view-shaped — they carry fully resolved model objects. The coordinator sits between them doing the fetch and conversion. Views never see a DeepLink.
Both URL schemes and universal links are handled identically because DeepLink.parse(from:) accepts either:
vinylcrate://album/12345
https://vinylcrate.app/album/12345
Same result. Same navigation path. The coordinator doesn’t know or care how the link arrived.
The Struggle Within
Passing model objects in route cases creates a Hashable conformance problem. ViewModelContainer, CollectionManager, and similar reference types need Hashable. The solution used here — computing id from the associated values and using that for both Equatable and Hashable — works, but it means two routes that carry different instances of the same model compare as equal if their IDs match. Usually that’s the right behavior. Occasionally it’s surprising.
There’s also the issue of routes carrying heavy objects. crateDetail carries a full ViewModelContainer. That’s convenient but it means the route can’t be easily serialized or state-restored across launches. If you want to persist navigation state, you need a serializable route representation separate from this runtime one. VinylCrate doesn’t do that yet — it’s a real limitation.
Finally, NavigationPath doesn’t expose its contents — it’s opaque by design. If you need to check whether a specific destination is already in the stack (to avoid duplicate pushes), SwiftUI gives you nothing. You have to maintain a parallel array yourself:
private(set) var stackContents: [NavigationTab: [Route]] = [:]
func navigate(to route: Route, on tab: NavigationTab? = nil) {
// ... existing logic ...
var contents = stackContents[targetTab] ?? []
contents.append(route)
stackContents[targetTab] = contents
}
func popToRoot(on tab: NavigationTab) {
navigationState.popToRoot(on: tab)
stackContents[tab] = []
}
func isInStack(_ route: Route, on tab: NavigationTab) -> Bool {
stackContents[tab]?.contains(route) ?? false
}
It’s not elegant, but it works. The real cost is keeping stackContents in sync — every push, pop, and tab switch needs to update both the NavigationPath and the parallel array. Most apps don’t need this. If yours does, budget for the maintenance.
The Pattern, Distilled
One enum that names every destination. One coordinator that owns navigation state and knows how to get there. Views that pull the coordinator from the environment and call it. Deep links that go through a two-stage resolution: raw identifiers in, model objects out.
The indirection costs something up front. The payoff is that navigation is auditable — you can grep navigate(to: and see the entire map of how your app moves. Deep links are just coordinator calls like any other. Destinations can be refactored without touching their callers. Tab state is independent and preserved.
For a five-tab app with a thousand records and barcode scanner entry points, that’s the right trade. The riff that plays the same way every time.