Coordinator: Key Concepts, Coordinator Pattern for iOS Navigation

Author: IT Sectr Published: 2026-02-18 Reading time: 9 min

Coordinator is an architectural navigation pattern that moves the logic of transitions between screens from ViewController into separate classes. The pattern was proposed by Soroush Khanlou in 2015 and has become widespread in the iOS community. Coordinator manages the application flow: creates and displays ViewController, passes data between screens, and handles flow completion. The pattern solves the Massive View Controller problem by extracting navigation from the controller. Learn more in the original article about Coordinator.

Key Takeaways

  • Coordinator — a navigation pattern that extracts transition logic from ViewController
  • Separation of concerns — ViewController manages UI, Coordinator manages navigation
  • Router — a helper component of Coordinator for abstracting UINavigationController
  • Flow — a sequence of screens managed by one Coordinator (e.g., onboarding)
  • Delegation — child coordinators report to the parent via delegate/protocol

What is Coordinator: The Essence of the Navigation Pattern

Coordinator is a pattern that takes responsibility for navigation in an iOS application. In standard UIKit, the ViewController itself manages transitions: present, push, show segue — all navigation methods are called from UIViewController. Coordinator extracts this logic: the ViewController reports an event (e.g., "the user clicked the login button"), the Coordinator decides which screen to show next. The ViewController is left only with UI logic and delegates navigation to the coordinator.

Pattern structure — CoordinatorProtocol with start() and finish() methods. start() — beginning of a flow: creating the first ViewController and displaying it. finish() — completing the flow with notification to the parent coordinator. Router — a wrapper around UINavigationController (or UISplitViewController), providing show, push, pop, dismiss methods. The coordinator does not work with UINavigationController directly — only through Router. This allows testing navigation and switching the UI framework.

ComponentRoleExample
CoordinatorNavigation flow managementAuthCoordinator, ProfileCoordinator
RouterAbstraction over UINavigationControllerpush, present, pop, dismiss
ViewControllerUI + event delegation to CoordinatorLoginViewController.delegate

Problems solved by Coordinator — Massive View Controller (navigation is a common cause of controller bloat). In standard UIKit, ViewController contains prepareForSegue, navigation delegates, unwind segue handling. Coordinator eliminates this. Storyboard segues are static connections between screens, Coordinator provides dynamic navigation with conditions. Testing navigation becomes possible: you can test Coordinator without UI by verifying the sequence of Router calls.

Coordinator in Swift: Implementation with Router and Flow

Basic Coordinator in Swift — a protocol with an associated type for Router and start/finish methods. Router — a protocol abstracting UINavigationController. A concrete Router implementation wraps UINavigationController and delegates methods to it. Coordinator accepts Router in init and uses it for navigation. Child coordinators are stored in the childCoordinators array for lifecycle management.

swift
// Router — navigation abstraction
protocol RouterProtocol: AnyObject {
    func push(_ viewController: UIViewController, animated: Bool)
    func pop(animated: Bool)
    func present(_ viewController: UIViewController, animated: Bool)
    func dismiss(animated: Bool)
}

final class NavigationRouter: RouterProtocol {
    private let navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func push(_ vc: UIViewController, animated: Bool) {
        navigationController.pushViewController(vc, animated: animated)
    }

    func pop(animated: Bool) {
        navigationController.popViewController(animated: animated)
    }

    func present(_ vc: UIViewController, animated: Bool) {
        navigationController.present(vc, animated: animated)
    }

    func dismiss(animated: Bool) {
        navigationController.dismiss(animated: animated)
    }
}

// Coordinator — flow management
protocol CoordinatorProtocol: AnyObject {
    var childCoordinators: [CoordinatorProtocol] { get set }
    var router: RouterProtocol { get }
    func start()
    func finish()
}

class AuthCoordinator: CoordinatorProtocol {
    var childCoordinators: [CoordinatorProtocol] = []
    let router: RouterProtocol

    init(router: RouterProtocol) {
        self.router = router
    }

    func start() {
        let loginVC = LoginViewController()
        loginVC.onLogin = { [weak self] in
            self?.showHome()
        }
        router.push(loginVC, animated: true)
    }

    private func showHome() {
        let homeCoordinator = HomeCoordinator(router: router)
        childCoordinators.append(homeCoordinator)
        homeCoordinator.start()
    }

    func finish() {
        childCoordinators.removeAll()
        router.pop(animated: true)
    }
}

Creating Coordinator in AppDelegate/SceneDelegate — AppDelegate or SceneDelegate creates UINavigationController, wraps it in NavigationRouter, creates a root Coordinator (AppCoordinator) and calls start(). AppCoordinator decides whether to show onboarding, login, or the main screen — depending on the application state. Coordinator is the single entry point for navigation, ViewController does not know about other screens.

Child Coordinators and Coordinator Hierarchy

Coordinator hierarchy — AppCoordinator → AuthCoordinator/MainCoordinator → ProfileCoordinator/SettingsCoordinator. The child coordinator is created by the parent and stored in the childCoordinators array. When the child coordinator completes its work, it calls finish() on the parent, and the parent removes it from childCoordinators. This prevents memory leaks: Coordinator holds a strong reference to ViewController (through Router), and without removal from childCoordinators, the object will not be deallocated.

swift
// Delegate for Coordinator -> Parent communication
protocol AuthCoordinatorDelegate: AnyObject {
    func authCoordinatorFinished(_ coordinator: AuthCoordinator)
}

class AuthCoordinator: CoordinatorProtocol {
    weak var delegate: AuthCoordinatorDelegate?

    func finish() {
        delegate?.authCoordinatorFinished(self)
    }
}

// AppCoordinator — parent
class AppCoordinator: AuthCoordinatorDelegate {
    func startAuthFlow() {
        let authCoordinator = AuthCoordinator(router: router)
        authCoordinator.delegate = self
        childCoordinators.append(authCoordinator)
        authCoordinator.start()
    }

    func authCoordinatorFinished(_ coordinator: AuthCoordinator) {
        childCoordinators.removeAll { $0 is AuthCoordinator }
        startMainFlow()
    }
}

Managing childCoordinators — dropping a Coordinator from the array is the only way to deallocate it. If you forget to remove a completed Coordinator, it stays in memory along with its ViewControllers. Recommended approaches: didMove(toParent:) of the parent, completion callback, or Combine publisher for automatic removal. The Coordinator pattern does not specify the notification mechanism — delegate, closure, or Combine — the choice is up to the developer.

Ways to Pass Data Between Coordinators

Passing data via delegate — the child Coordinator defines a delegate protocol with methods through which results are passed: func authCoordinator(_:didLoginWith user: User). The parent implements the protocol and receives data when the child flow completes. This is type-safe and explicit. Drawback: each child Coordinator requires a separate protocol. For projects with 10+ Coordinators, this leads to an increase in files.

Passing data via Result type — the finish method accepts Result, where Output is a generic type for the flow result. Coordinator — a generic with an associated result type. start() with callback: start(completion: @escaping (Output) -> Void). This reduces code: no need to write a separate protocol for each coordinator. RxSwift/Combine: Coordinator publishes the result via PassthroughSubject/Publisher. The choice depends on the team's architectural approach.

MethodProsCons
DelegateType-safe, explicit, separate protocolsMany protocols, lots of boilerplate
ClosureCompact, fewer filesHard to debug retain cycles
Combine/RxReactive, easy to combineLibrary dependency, harder to debug

Shared data layer — Coordinators do not pass data directly but use a shared service/repository. AuthCoordinator saves the token in Keychain/UserDefaults, ProfileCoordinator reads from there. Coordinators communicate through shared state (Dependency Injection container) rather than direct calls. This reduces coupling between Coordinators but creates implicit dependencies on shared state.

Comparison of Coordinator with Router, VIPER and MVVM-C

Coordinator vs Router — Router is a component of Coordinator that abstracts UINavigationController. Coordinator is responsible for the flow (which screen to show), Router — for the mechanics (how to show: push/present). Router is the "how", Coordinator is the "what". You can use Router without Coordinator (e.g., Navigator singleton), but Coordinator without Router is just a ViewController with a different abstraction. Usually both patterns are used together.

Coordinator vs VIPER — VIPER has a Wireframe component responsible for navigation — analogous to Coordinator. In VIPER, Wireframe is part of the module, Coordinator is a separate layer above modules. A VIPER module (View-Interactor-Presenter-Entity-Router) includes navigation as part of the module. Coordinator is external to modules: it creates and connects modules but is not part of them. Coordinator is more flexible for reusing screens in different flows.

MVVM-C — an extension of MVVM with Coordinator. ViewModel does not know about Coordinator directly — ViewController delegates navigation through ViewModel, ViewModel calls the coordinator through a protocol. MVVM-C is the standard approach for iOS projects with SwiftUI: Coordinator manages NavigationStack or fullScreenCover, ViewModel calls the coordinator by publishing state. Apple does not recommend Coordinator for SwiftUI — NavigationStack and NavigationPath are built-in navigation mechanisms.

swift
// MVVM-C: ViewModel calls Coordinator through a protocol
protocol AuthNavigationProtocol: AnyObject {
    func showMainScreen()
    func showForgotPassword()
}

class AuthViewModel: ObservableObject {
    weak var navigation: AuthNavigationProtocol?

    func loginTapped() {
        // logic...
        navigation?.showMainScreen()
    }
}

Frequently Asked Questions

Is Coordinator needed for SwiftUI?

For SwiftUI, built-in navigation (NavigationStack, NavigationPath) often replaces Coordinator. Apple recommends path-based navigation. Coordinator makes sense for complex flows with deep conditions (onboarding-login-main screen depending on role). For simple SwiftUI applications, Coordinator is redundant — use NavigationPath.

Is Coordinator a Router?

No, these are different patterns. Coordinator manages the navigation flow: decides which screen to show, creates ViewControllers and connects them. Router is an abstraction over UINavigationController: push, present, pop, dismiss. Coordinator uses Router to perform navigation. In some implementations, Router includes Coordinator logic (Router-per-screen), but this deviates from the original pattern.

How to avoid retain cycle in Coordinator?

Two main sources of leaks: childCoordinators (parent holds a child, forgetting to remove it) and Router (UINavigationController holds ViewController). Solution: always remove the child Coordinator from the array upon finish(). Use weak reference for delegate. For Router — do not hold a strong reference to UINavigationController if it is already in the window hierarchy. Test Coordinator deinit.

When is Coordinator overkill?

For applications with 3-5 screens, Coordinator is overkill — segue or simple navigationController.pushViewController is easier. For SwiftUI applications with NavigationStack — also redundant. Coordinator is justified for applications with 15+ screens, complex flows (onboarding with branching, authorization with password recovery) and mixed UIKit/SwiftUI projects.

How to test Coordinator?

Mock Router — check which methods are called and with what parameters. Check childCoordinators: after start() the array is not empty, after finish() — empty. Coordinator is tested without UI: Router is a protocol, its mock does not require UIKit. Use XCTestExpectation for async flow. In Android — similar testing of NavigationController and NavHost with mock navigation.

Summary

  • Coordinator — a navigation pattern that extracts transitions from ViewController into a separate class
  • Router — an abstraction of UINavigationController used by Coordinator
  • Hierarchy — parent and child Coordinators with result delegation
  • MVVM-C — standard approach for UIKit projects with Coordinator
  • SwiftUI — built-in navigation via NavigationStack replaces Coordinator
  • Testing — Coordinator is tested through mock Router without UIKit

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also