AG
Writing
Engineering6 min read

Mastering SwiftUI Navigation: A Centralized Coordinator for Complete App Flows

A single NavigationCoordinator that owns your stack, sheets, and auth/onboarding state — so SwiftUI navigation stays predictable as the app grows.

Charith 'Alex' Gunasekara

Charith 'Alex' Gunasekara

Head of Development & Engineering

Originally published on Medium
SwiftUIiOSNavigationMobile

Navigating a SwiftUI app can feel like a puzzle — stacked views, unpredictable back buttons, and sheets that don't play nicely with the rest of your flow. As an app grows, nested NavigationStacks and ad-hoc @State flags turn into a maze that's hard to reason about and harder to change.

The fix I keep reaching for is a single source of truth: one coordinator that owns navigation for the whole app. It centralizes the stack, the sheets, and the high-level state (onboarding complete? authenticated?) so every screen just asks the coordinator to move — and the flow stays clean and predictable.

The approach

Three small pieces do the work:

  • AppRoute — an enum of every stack destination.
  • SheetRoute — an enum of modal/sheet overlays.
  • NavigationCoordinator — an observable object that drives the NavigationStack and presents sheets.

Defining the routes

AppRoute enumerates the screens you can push onto the stack. Because the cases have no associated values yet, it gets Hashable for free:

enum AppRoute: Hashable {
    case welcomeLanding
    case signIn
    case signUp
    case dashboard
    case profile
    case forgotPassword
    case menu
}

Sheets are separate — they're overlays, not stack destinations — so they get their own type:

enum SheetRoute: Identifiable {
    case greetings
    var id: Self { self }
}

The NavigationCoordinator

The coordinator publishes the NavigationPath, the active sheet, and the two pieces of state that decide where the app should start. Persisting hasCompletedWelcome and isAuthenticated to UserDefaults means the app reopens exactly where the user left off:

class NavigationCoordinator: ObservableObject {
    @Published var path = NavigationPath()
    @Published var activeSheet: SheetRoute?
 
    @Published var hasCompletedWelcome: Bool {
        didSet {
            UserDefaults.standard.set(hasCompletedWelcome, forKey: "hasCompletedWelcome")
            updateInitialRoute()
        }
    }
 
    @Published var isAuthenticated: Bool {
        didSet {
            UserDefaults.standard.set(isAuthenticated, forKey: "isAuthenticated")
            updateInitialRoute()
        }
    }
 
    init() {
        self.hasCompletedWelcome = UserDefaults.standard.bool(forKey: "hasCompletedWelcome")
        self.isAuthenticated = UserDefaults.standard.bool(forKey: "isAuthenticated")
        updateInitialRoute()
    }
 
    func updateInitialRoute() {
        var newPath = NavigationPath()
        if !hasCompletedWelcome {
            newPath.append(AppRoute.welcomeLanding)
        } else if !isAuthenticated {
            newPath.append(AppRoute.signIn)
        } else {
            newPath.append(AppRoute.dashboard)
        }
        path = newPath
    }
 
    func push(_ route: AppRoute) { path.append(route) }
    func pop() { if !path.isEmpty { path.removeLast() } }
    func popToRoot() { path.removeLast(path.count) }
    func presentSheet(_ route: SheetRoute) { activeSheet = route }
    func dismissSheet() { activeSheet = nil }
}

The nice property here: changing isAuthenticated or hasCompletedWelcome automatically recomputes the initial route. Log out, and the stack resets to sign-in — no manual cleanup at the call site.

Wiring up the RootView

RootView binds the coordinator's path to a NavigationStack and maps each AppRoute to its screen. Sheets are handled separately with .sheet(item:):

struct RootView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        NavigationStack(path: $navigationCoordinator.path) {
            Color.clear
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .welcomeLanding:
                        WelcomeView()
                    case .signIn:
                        LoginView()
                    case .signUp:
                        SignUpFirstView()
                    case .dashboard:
                        DashboardView()
                    case .profile:
                        ProfileView()
                    case .forgotPassword:
                        ForgotPasswordView()
                    case .menu:
                        MenuView()
                    }
                }
        }
        .sheet(item: $navigationCoordinator.activeSheet) { route in
            switch route {
            case .greetings:
                GreetingsView()
            }
        }
    }
}

Inject the coordinator once at the app entry point so every screen can reach it:

@main
struct MyApp: App {
    @StateObject private var navigationCoordinator = NavigationCoordinator()
 
    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(navigationCoordinator)
        }
    }
}

Navigation in action

Every screen pulls the coordinator from the environment and asks it to move. No screen needs to know what comes next — it just expresses intent.

WelcomeView flips the onboarding flag, which re-routes the app automatically:

struct WelcomeView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Text("Welcome View")
            Button("Get Started") {
                navigationCoordinator.hasCompletedWelcome = true
            }
        }
    }
}

LoginView is a hub with several paths — straight to the dashboard, a sheet, or deeper into the stack:

struct LoginView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Button("Direct Dashboard") {
                navigationCoordinator.isAuthenticated = true
            }
            Button("Greetings Sheet") {
                navigationCoordinator.presentSheet(.greetings)
            }
            Button("Sign Up") {
                navigationCoordinator.push(.signUp)
            }
            Button("Forgot Password") {
                navigationCoordinator.push(.forgotPassword)
            }
        }
    }
}

DashboardView pushes the menu:

struct DashboardView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Text("Dashboard")
            Button("Menu") {
                navigationCoordinator.push(.menu)
            }
        }
    }
}

GreetingsView is the sheet — it can dismiss itself and advance the flow:

struct GreetingsView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Text("Hello Alex... This is greetings view.")
            Button("Continue") {
                navigationCoordinator.dismissSheet()
                navigationCoordinator.isAuthenticated = true
            }
            Button("Dismiss") {
                navigationCoordinator.dismissSheet()
            }
        }
    }
}

MenuView shows how a single screen can push, pop, and log out:

struct MenuView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Button("Profile") {
                navigationCoordinator.push(.profile)
            }
            Button("Go Back") {
                navigationCoordinator.pop()
            }
            Button("Logout") {
                navigationCoordinator.isAuthenticated = false
            }
        }
    }
}

ProfileView just pops back:

struct ProfileView: View {
    @EnvironmentObject private var navigationCoordinator: NavigationCoordinator
 
    var body: some View {
        VStack {
            Text("Profile View")
            Button("Go Back") {
                navigationCoordinator.pop()
            }
        }
    }
}

Passing models between views

Real flows need to carry data — a sign-up that spans two screens, for example. You can give an AppRoute case an associated value, but then you have to make the enum Hashable yourself. Hash on a stable identifier so SwiftUI can tell instances apart:

class SignUpViewModel: ObservableObject, Identifiable {
    @Published var email: String = ""
    @Published var password: String = ""
    let id = UUID()
}
enum AppRoute: Hashable {
    case signUpSecondStep(viewModel: SignUpViewModel)
 
    func hash(into hasher: inout Hasher) {
        switch self {
        case .signUpSecondStep(let viewModel):
            hasher.combine("signUpSecondStep")
            hasher.combine(viewModel.id)
        }
    }
 
    static func == (lhs: AppRoute, rhs: AppRoute) -> Bool {
        switch (lhs, rhs) {
        case (.signUpSecondStep(let lhsVM), .signUpSecondStep(let rhsVM)):
            return lhsVM.id == rhsVM.id
        default:
            return false
        }
    }
}

The first step owns the view model and passes it forward on push:

struct SignUpFirstView: View {
    @EnvironmentObject var navigationCoordinator: NavigationCoordinator
    @StateObject private var viewModel = SignUpViewModel()
 
    var body: some View {
        VStack {
            TextField("Email", text: $viewModel.email)
            SecureField("Password", text: $viewModel.password)
            Button("Next") {
                navigationCoordinator.push(.signUpSecondStep(viewModel: viewModel))
            }
        }
        .navigationTitle("Sign Up - Step 1")
    }
}

Add the case to RootView's switch so it builds the second screen with the passed-in model:

case .signUpSecondStep(let viewModel):
    SignUpSecondView(viewModel: viewModel)

And the second step simply observes what it was handed:

struct SignUpSecondView: View {
    @ObservedObject var viewModel: SignUpViewModel
 
    var body: some View {
        VStack {
            Text("Email: \(viewModel.email)")
            Text("Password: \(viewModel.password)")
        }
        .navigationTitle("Sign Up - Step 2")
    }
}

Takeaway

A complete SwiftUI navigation system powered by a centralized NavigationCoordinator keeps your app's flow clean and predictable: one place owns the stack, one place owns the sheets, and screens express intent instead of wiring each other together. It scales from a two-screen onboarding to a full app without turning into a maze.

Try it in your next project, tweak it for your needs, and let me know how it goes — I'd love to hear your feedback.

ShareLinkedInX

Keep reading