Skip to Content
Course content

145: Coordinator Pattern for Navigation

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this pattern in almost every junior-to-mid-level codebase I've ever reviewed: the ViewController that does everything. Specifically, I'm talking about the habit of letting your ViewControllers handle their own navigation. You'll see a line like self.navigationController?.pushViewController(DetailViewController(id: product.id), animated: true) sitting right inside a button tap handler. It seems intuitive at first, but it's a trap.

"Navigation is just a ViewController's job"

The misconception here is that since the ViewController is the one reacting to the user's tap, it should be the one deciding where the user goes next. On the surface, it works. But look at what happens as your app grows. If your ProductListViewController is responsible for pushing the ProductDetailViewController, the list screen now needs to know exactly how to initialize the detail screen, what dependencies that detail screen requires (like a database service or an API client), and how it's presented.

This creates tight coupling. If you ever want to reuse that ProductListViewController in a different part of the app—say, a "Favorites" tab—but you want it to push a different detail screen there, you're stuck. You've baked the navigation logic directly into the view logic. It makes your tests brittle and your ViewControllers bloated.

Extracting the flow into a dedicated Coordinator

The Coordinator pattern solves this by moving the "how do I get from A to B" logic into a separate object. Think of the Coordinator as the conductor of an orchestra; the musicians (the ViewControllers) play their parts, but they don't decide when the next movement starts.

To do this, we start with a protocol. I usually keep it simple: a start() method to kick off the flow. In a real-world shopping app, a ShopCoordinator might look like this:

protocol Coordinator {
    func start()
}

class ShopCoordinator: Coordinator {
    var navigationController: UINavigationController

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

    func start() {
        let vc = ProductListViewController()
        vc.coordinator = self // We give the VC a reference back to us
        navigationController.pushViewController(vc, animated: false)
    }

    func showProductDetail(productId: String) {
        let vc = ProductDetailViewController(productId: productId)
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }
}

Notice that the ShopCoordinator owns the UINavigationController. It's the only object that knows about the navigation stack. The ViewControllers are now blissfully ignorant of who comes after them.

Letting the ViewController ask, not command

Now, how does the ProductListViewController actually trigger that navigation? Instead of pushing a new VC, it tells its coordinator that an event happened. I prefer using a simple property on the VC, though some developers prefer delegates. Here is how I'd implement the trigger in the View Controller:

class ProductListViewController: UIViewController {
    weak var coordinator: ShopCoordinator?

    func didSelectProduct(_ product: Product) {
        // Instead of pushing, we simply notify the coordinator
        coordinator?.showProductDetail(productId: product.id)
    }
}

I use a weak reference to the coordinator here. This is crucial. If the coordinator owns the navigation controller, and the navigation controller owns the view controller, and the view controller owns the coordinator... you've just created a retain cycle, and your memory will leak like a sieve.

By shifting the responsibility, you've achieved something powerful: ProductListViewController is now completely reusable. It doesn't care if it's being used in a Shop flow, a Search flow, or a Recommendation flow. It just says, "Hey, a product was selected," and lets the coordinator handle the routing logic based on the current context.




📋 Practical Task

Implementing a User Onboarding Flow Coordinator

You are tasked with building the navigation logic for a new user onboarding sequence. The flow must be: WelcomeViewControllerAccountCreationViewControllerDashboardViewController.

Requirements:

  • Create an OnboardingCoordinator class that conforms to a Coordinator protocol.
  • The coordinator should hold a reference to a UINavigationController.
  • Implement a start() method that pushes the WelcomeViewController.
  • Implement two additional methods in the coordinator: showAccountCreation() and showDashboard().
  • In the WelcomeViewController and AccountCreationViewController, add a weak var coordinator property.
  • Inside the ViewControllers, simulate a button press (e.g., a function called didTapNext()) that calls the appropriate method on the coordinator to move to the next screen.

Goal: Ensure that none of the ViewControllers call pushViewController directly. All navigation must be routed through the OnboardingCoordinator.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.