6 minute read

What is the Coordinator Pattern in iOS (Swift)

The Coordinator Pattern is a key architecture in modern iOS app development when you want to maintain clean, modular, and scalable code. Its main mission: to separate navigation logic from the views, which makes your code easier to maintain and test.

Origin of the Coordinator Pattern

  • Emerged in UIKitt: The Coordinator pattern appeared in the iOS community to solve the limitations of view controllers (UIViewController) in UIKit, which tended to get mixed with navigation, network, and UI logic, leading to very large and hard-to-reuse classes.
  • Inspired by design principles like Separation of Concerns and the Single Responsibility Principle.
  • It was introduced and popularized by Soroush Khanlou in his article “Coordinators Redux” and adopted by the community to manage complex navigation flows.

Part I: Historical and Conceptual Foundations

The Original Problem in UIKit: Before the Coordinator pattern, iOS applications suffered from the “Massive View Controller Problem”:

class ProductListViewController: UIViewController {
    // Manage the UI
    @IBOutlet weak var tableView: UITableView!
    
    // Manage data
    var products: [Product] = []
    
    // Handles navigation
    func didTapProduct(_ product: Product) {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let detailVC = storyboard.instantiateViewController(withIdentifier: "ProductDetail") as! ProductDetailViewController
        detailVC.product = product
        navigationController?.pushViewController(detailVC, animated: true)
    }
}

Problems:

  • The controller knows too much about other screens
  • Difficult to test navigation
  • Changing the flow requires modifying multiple files
  • Impossible to reuse components in different contexts
  • High coupling between components

The Solution: Separating Responsibilities

The Coordinator pattern applies the single responsibility principle:

  • View: Only presents information and responds to interactions
  • Coordinator: Only decides what to show and when
  • Model/ViewModel: Only handles data and business logic

Part II: Evolution of the Pattern towards SwiftUI

SwiftUI introduced a declarative paradigm but initially lacked robust tools for programmatic navigation. With iOS 16, Apple introduced NavigationStack and NavigationPath, finally allowing the Coordinator pattern to be implemented natively

From NavigationView to NavigationStack

// Before IOS 16 (deprecated)
NavigationView {
    ContentView()
        .navigationDestination(for: String.self) { value in
            
        }
}
// (iOS 16+)
NavigationStack(path: $coordinator.navigationPath) {
    ContentView()
        .navigationDestination(for: Route.self) { route in
            
    }
}

With the release of iOS 16, Apple deprecated the original NavigationView and introduced NavigationStack to create a stack of views. This newer API provides a more flexible and powerful way to handle navigation in SwiftUI, resolving many of the limitations of its predecessor. NavigationStack allows for a stack-based approach, similar to UIKit’s navigation controllers, and enables state-driven, programmatic navigation. NavigationStack works alongside NavigationPath, which manages the navigation path dynamically. This combination makes it easier to implement complex navigation flows and pop back to specific views in the hierarchy, a common challenge with the older NavigationView. These tools provide the foundation for implementing a clean Coordinator pattern directly within SwiftUI, centralizing navigation logic and keeping views decoupled and reusable.

Doc: NavigationStack

Part III: Architecture Pattern

AppNavigation: The Root View That Drives the App

public struct AppNavigation: View {
    
    @State private var coordinator: AppCoordinator = .init(root: .splash)

    public init() {}

    public var body: some View {
            Group {
                switch coordinator.root {
                case .splash:
                    EmptyView()
                    //.transition
                case .login:
                    EmptyView()
                    //.transition
                case .onboarding:
                    EmptyView()
                    //.transition
                default:
                    EmptyView()
                    //.transition
                }
            }
            .environment(\.appCoordinator, coordinator)
            //.animation(., value: coordinator.root)
    }
}

This view acts as the “stage director,” deciding which main screen to display based on the current state stored in coordinator.root. It uses a switch to choose the screen (here with EmptyView(), but in your case, it would be your specific view), and when root is updated, SwiftUI changes the interface. The coordinator is injected into the environment so that child views can easily access it. @State makes the coordinator reactive, causing the interface to update automatically when root changes.

AppCoordinator: The Navigation’s Orchestra Director

import SwiftUI

public enum RootView: Sendable {
    case splash
    case login
    case register
    case home
    case onboarding
}

@Observable
public final class AppCoordinator {
    public var root: RootView

    public init(root: RootView) {
        self.root = root
    }
}

public extension EnvironmentValues {
    @Entry var appCoordinator: AppCoordinator?
}

Here, RootView enumerates the main states or screens the app can navigate to (like splash, login, home, etc.). AppCoordinator is an observable class that exposes the root property, whose changes determine the current screen. The extension with @Entry allows for this instance to be easily and automatically injected into the SwiftUI environment, making it simple to access in any view without manually passing parameters.

Why @Observable?

Apple introduced the @Observable macro in iOS 17 as a more efficient replacement for ObservableObject. It reduces update overhead and improves performance.

Why @Entry?

The @Entry macro, introduced with Xcode 16 and Swift 5.9, dramatically simplifies the creation of custom environment values in SwiftUI. Its purpose is to eliminate the boilerplate code that was previously required to extend EnvironmentValues. Before @Entry, developers had to create a custom key conforming to the EnvironmentKey protocol and then write a computed property in an extension of EnvironmentValues. The @Entry macro automates this process, allowing you to define a new environment value with a single line of code. Although it was introduced in 2024, the code it generates is backward-compatible with iOS versions as early as iOS 13.

// Before IOS 17
private struct AppCoordinatorKey: EnvironmentKey {
    static let defaultValue: AppCoordinator? = nil
}

extension EnvironmentValues {
    var appCoordinator: AppCoordinator? {
        get { self[AppCoordinatorKey.self] }
        set { self[AppCoordinatorKey.self] = newValue }
    }
}

// @Entry (iOS 17+)
public extension EnvironmentValues {
    @Entry var appCoordinator: AppCoordinator?
}

Feature Coordinators: True Modularity

extension SplashScreen {
    
    enum Navigation {
        case login
        case main
        case onboarding
    }

    @Observable
    class ViewModel {
        
        var navigation: Navigation? = nil

    }

Feature Coordinators is an architectural technique in Swift (and SwiftUI) where each “feature” or module of an app—such as authentication, home, profile, or settings—has its own coordinator object responsible for the navigation and flow management within that specific section. This strategy maximizes the separation of responsibilities principle, allowing each section of the app to be modular, maintainable, and reusable.

Advantages?

  • Avoids coupling: Each feature manages its own flow without depending on the rest.
  • Facilitates maintenance: Changes in one part of the app do not affect others.
  • Allows for reusability: Features like the login flow can be integrated into other apps or contexts.

Part IV: Complete System Implementation

App Entry Point

struct JorgemrhtApp: App {

    var body: some Scene {
        WindowGroup {
            AppNavigation()
        }
}

Within the main scene (WindowGroup), the AppNavigation view is loaded, which contains all the navigation logic and the app’s root interface.

Coordinator Views: The Bridge Between Logic and UI

struct LoginScreen : View {
    
    @State private var vm: ViewModel
    @Environment(\.appCoordinator) private var coordinator: AppCoordinator?

    init() {
        _vm = .init(wrappedValue: .init())
    }
    
    var body: some View {
        
        VStack(alignment: .center) {
            Text("Login")
        }
        .onChange(of: vm.navigation) { _, newValue in
            guard let newValue else { return }
            switch newValue {
            case .main:
                coordinator?.root = .home
            case .register:
                coordinator?.root = .register
            }
        }
    }
}

This view represents the login screen. It is tied to a ViewModel that controls its state and logic. It observes changes in the ViewModel’s navigation property to know when to change screens. When navigation changes to .main or .register, the view simply delegates to the AppCoordinator to update the global state and change the screen, thus showing “home” or “register.” This keeps the view clean, without embedded navigation logic.

extension LoginScreen {
    
    enum Navigation {
        case main
        case register
    }
    
    @Observable
    class ViewModel {
        
        var navigation: Navigation? = nil
    }

The ViewModel defines the possible navigation routes (like going to the main screen or registration) using a Navigation enum. Its navigation property acts as an intermediary to communicate navigation intentions. The view observes this property and responds accordingly. This separation keeps the UI free of logic and facilitates testing and reusability.

Meta

Updated: