Navigating SwiftUI with an Enum Router (Cyclical A → B → C Flow) — A Simple example#

Introduction#

In this post, we’ll explore a lightweight, type‑safe way to manage navigation in SwiftUI by modelling screens as an enum and driving transitions with closures. We’ll illustrate a simple cyclical flow through three views—A → B → C → A—and then dive into the pros and cons of this approach.


1. Defining the Router State#

First, we define an enum with three cases—one for each screen. This enum is our “single source of truth” for navigation state:

// Represents each screen in our app.
enum AppScreen {
    case viewA
    case viewB(someData: String) // We can pass values to next screens
    case viewC
}

2. The Router Protocol & Default Implementation#

Next, we encapsulate navigation logic in a router object. Here’s a minimal protocol and default router using @Observable (iOS 17+), so SwiftUI updates automatically when currentScreen changes:

import Observation

protocol AppRouter {
    var currentScreen: AppScreen { get }
    func navigate(to screen: AppScreen)
}

@Observable
final class DefaultAppRouter: AppRouter {
    private(set) var currentScreen: AppScreen = .viewA
    
    func navigate(to screen: AppScreen) {
        currentScreen = screen
    }
}

3. The Navigation Container#

We centralise navigation in one SwiftUI container. It switches over router.currentScreen and injects closures into each view to advance the flow cyclically:

import SwiftUI

struct CyclicalNavigationView: View {
    @State private var router = DefaultAppRouter()
    
    var body: some View {
        content
    }
    
    @ViewBuilder
    private var content: some View {
        switch router.currentScreen {
        case .viewA:
            ViewA { receivedData in // We capture the data send by ViewA.
                // We send the data to ViewB using the router.
                router.navigate(to: .viewB(someData: receivedData))
            }
        // We get the data from previous state and inject it when creating ViewB.
        case .viewB(let someData):
            ViewB(dataFromViewA: someData) {
                router.navigate(to: .viewC)
            }
        case .viewC:
            ViewC {
                router.navigate(to: .viewA)
            }
        }
    }
}

4. The Three Views#

Each view is oblivious to the overall navigation logic—it simply calls its onNext closure when the user taps a button:

struct ViewA: View {
    let onNext: (String) -> Void // The closure receives tha data we want to pass to the next state.
    
    var body: some View {
        VStack {
            Text("View A")
            Button("Go to B") {
                // We pass data to next state using the closure argument.
                onNext("some Data from screen A")
            }
        }
    }
}

struct ViewB: View {
    // We optionally received some data from the router previous state.
    let dataFromViewA: String?
    let onNext: () -> Void
    
    var body: some View {
        VStack {
            Text("View B")
                .font(.title)
            if let dataFromViewA {
                Text(dataFromViewA)
                    .font(.caption)
            }
            Button("Go to C", action: onNext)
        }
    }
}

struct ViewC: View {
    let onNext: () -> Void
    
    var body: some View {
        VStack {
            Text("View C")
            Button("Go to A", action: onNext)
        }
    }
}

5. How It Works#

  1. Initial State The router starts at .viewA, so ViewA is presented.
  2. Advancing Tapping “Go to B” calls router.navigate(to: .viewB), updating currentScreen. SwiftUI recomputes the switch and shows ViewB. Optionally if data must be shared or passed, we can use associated values and closure arguments to pass the data between states.
  3. Cyclical Flow Similarly, B → C and C → A form a loop. There’s no back‑stack—each transition replaces the current screen.

Here is the navigation working:

State Drive navigation example

6. Pros and Cons#

Pros:#

This enum‑and‑closure router is completely type‑safe and exhaustive—every possible screen must be handled in your switch, so you’ll never accidentally forget a case. All of your navigation state lives in one place, making it easy to reason about where the app is and simple to write unit tests against your navigate(to:) calls. Because each view only knows about its own onNext closure, your views stay decoupled from routing details and remain highly reusable. You can also test the router in isolation—just invoke navigate(to:) and assert that currentScreen changes as expected. Finally, modelling screens as an enum feels very “functional,” with no magic strings or identifiers floating around.

Cons:#

On the flip side, there’s no built‑in history or back‑stack: each transition outright replaces the current screen, so if you need “go back” behaviour you’ll have to roll your own stack. You also miss out on SwiftUI’s native push/pop animations and swipe‑back gestures, unless you add transitions manually or switch to NavigationStack. As your app grows, the central switch can become large and repetitive, leading to boilerplate that’s harder to maintain. Supporting deep‑linking or state restoration means you must write custom serialization for your enum state. And finally, this pattern is best suited to single, linear (or cyclical) flows—if you have branching paths, modals or nested stacks, you’ll likely need a more sophisticated coordinator.

7. Conclusion#

This enum‑and‑closure router shines for simple, linear or cyclical flows. It’s clear, type‑safe and easy to test. However, if you need: • A back‑stack with swipe‑back animations • Deep‑linking or state‑restoration support • Complex branching or modal flows

you might migrate to SwiftUI’s NavigationStack (iOS 16+) or adopt a more full‑featured coordinator. For many lightweight apps—like our A → B → C cycle—this pattern is both elegant and practical.