Mastering Navigation With Coordinators

Adopting the Coordinator Pattern

Download Your Free Copy of
The Missing Manual
for Swift Development

The Guide I Wish I Had When I Started Out

Join 20,000+ Developers Learning About Swift Development

Download Your Free Copy

The previous episode zoomed in on the drawbacks of the UIKit framework. The coordinator pattern can help us work around these limitations. Coordinators are in some ways similar to view models. A coordinator is nothing more than an object that removes a responsibility from a view controller. It is responsible for navigation and defines the flow of the application.

The application we explored in the previous episode is simple and a fine example to illustrate how the coordinator pattern fits into the Model-View-Controller pattern. In this episode, we refactor Quotes by integrating a coordinator.

Creating a Coordinator

Let's start by creating a coordinator. Create a new group and name it Coordinators. We create a separate group for the project's coordinators because it's possible for more complex projects to have multiple coordinators. Add a new Swift file to the Coordinators group and name it AppCoordinator.swift. The coordinator that bootstraps the application is usually named AppCoordinator or MainCoordinator. Its name indicates that it's the first coordinator that is instantiated. Don't worry about this for now.

Add import statements for the UIKit and Foundation frameworks. We import the UIKit framework because the coordinator will interact with UIKit to present and dismiss view controllers. Define a class with name AppCoordinator.

import UIKit
import Foundation

class AppCoordinator {

}

The Quotes project isn't complex, which means we can keep the AppCoordinator class simple. The AppCoordinator class will be responsible for navigating the application, which implies that it needs access to a UINavigationController instance. We create a UINavigationController instance and store a reference to it in a private, constant property with name navigationController.

import UIKit
import Foundation

class AppCoordinator {

    // MARK: - Properties

    private let navigationController = UINavigationController()

}

We also need to make a few changes to the storyboard. Open Main.storyboard. The navigation controller is managed by the AppCoordinator class, which means we can remove the navigation controller of the storyboard.

Main Storyboard

By removing the navigation controller, the storyboard no longer has an initial view controller. To instantiate the quotes view controller from the storyboard, we need to assign it a storyboard identifier. Select the quotes view controller and open the Identity Inspector on the right. Set Storyboard ID to QuotesViewController.

Main Storyboard

By assigning a storyboard identifier to the quotes view controller, we can ask the storyboard to instantiate it.

Instantiating the Coordinator

The application delegate instantiates and keeps a reference to the application coordinator. Open AppDelegate.swift. We create an AppCoordinator instance and store a reference to it in a private, constant property with name appCoordinator.

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    // MARK: - Properties

    var window: UIWindow?

    // MARK: -

    private let appCoordinator = AppCoordinator()

    ...

}

Build and run the application to see what we have so far. We're presented with a black screen instead of the quotes view controller. Something's not quite right. Let me explain what happened.

Quotes

Application Launch Sequence

Open the Quotes project in the project navigator. Select the Quotes target from the list of targets under the General tab. We're interested in the Deployment Info section. When the application launches, the UIKit framework instantiates the initial view controller of the storyboard that is set as the Main Interface, Main.storyboard in this example. The initial view controller of the storyboard is set as the root view controller of the application window.

Target Configuration

We removed the navigation controller from the storyboard and, at the same time, the initial view controller of the storyboard. The UIKit framework doesn't know which view controller it should instantiate and set as the root view controller of the application window. The changes we need to make to resolve the issue are straightforward.

Open AppDelegate.swift. The magic happens in the application(_:didFinishLaunchingWithOptions:) method. UIKit creates and configures the application window for us if (1) we specify an initial storyboard and if (2) that storyboard contains an initial view controller. Because the second requirement isn't met, we need to create the application window manually and set its root view controller. This isn't difficult, though. We instantiate an instance of the UIWindow class using the bounds of the screen as the frame of the application window. We assign the UIWindow instance to the window property of the AppDelegate instance.

// MARK: - Application Life Cycle

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Initialize Window
    window = UIWindow(frame: UIScreen.main.bounds)

    return true
}

We also need to set the root view controller of the application window. The root view controller is the navigation controller of the AppCoordinator instance. Open AppCoordinator.swift. Because we don't want to expose the UINavigationController instance to the rest of the project, we define a computed property, rootViewController, of type UIViewController. In the closure of the computed property, we return a reference to the UINavigationController instance.

import UIKit
import Foundation

class AppCoordinator {

    // MARK: - Properties

    private let navigationController = UINavigationController()

    // MARK: - Public API

    var rootViewController: UIViewController {
        return navigationController
    }

}

In application(_:didFinishLaunchingWithOptions:), we assign the value returned by the rootViewController property of the application coordinator to the rootViewController property of the application window.

// MARK: - Application Life Cycle

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Initialize Window
    window = UIWindow(frame: UIScreen.main.bounds)

    // Configure Window
    window?.rootViewController = appCoordinator.rootViewController

    return true
}

Last but not least, we call makeKeyAndVisible() on the application window to show it and correctly position it.

// MARK: - Application Life Cycle

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Initialize Window
    window = UIWindow(frame: UIScreen.main.bounds)

    // Configure Window
    window?.rootViewController = appCoordinator.rootViewController

    // Make Key and Visible
    window?.makeKeyAndVisible()

    return true
}

Build and run the application to see the result. The black screen we encountered earlier should now be replaced with a black screen and a navigation bar at the top.

We're almost there. The navigation controller of the application coordinator is the root view controller of the application window, but the navigation controller doesn't have a root view controller it can display.

Instantiating the Quotes View Controller

We initiate the application flow by starting the coordinator. This is as simple as defining a start() method in the AppCoordinator class and invoking it in the application(_:didFinishLaunchingWithOptions:) method. Open AppCoordinator.swift and define a method with name start().

import UIKit
import Foundation

class AppCoordinator {

    // MARK: - Properties

    private let navigationController = UINavigationController()

    // MARK: - Public API

    var rootViewController: UIViewController {
        return navigationController
    }

    // MARK: -

    func start() {

    }

}

In the start() method, we invoke a helper method, showQuotes().

func start() {
    showQuotes()
}

The implementation of the showQuotes() method is straightforward. We instantiate an instance of the QuotesViewController class and push it onto the navigation stack of the navigation controller.

The application coordinator loads the main storyboard and instantiates the view controller with storyboard identifier QuotesViewController. Remember that we assigned this storyboard identifier to the quotes view controller in Main.storyboard earlier in this episode. We cast the result to an instance of the QuotesViewController class. If the instantiation of the quotes view controller fails, a fatal error is thrown in the else clause of the guard statement because the instantiation of the quotes view controller should never fail. With the quotes view controller instantiated, we push it onto the navigation stack.

// MARK: - Helper Methods

private func showQuotes() {
    // Initialize Quotes View Controller
    guard let quotesViewController = UIStoryboard(name: "Main", bundle: .main).instantiateViewController(withIdentifier: "QuotesViewController") as? QuotesViewController else {
        fatalError("Unable to Instantiate Quotes View Controller")
    }

    // Push Quotes View Controller Onto Navigation Stack
    navigationController.pushViewController(quotesViewController, animated: true)
}

The last piece of the puzzle is invoking the start() method in the application(_:didFinishLaunchingWithOptions:) method.

// MARK: - Application Life Cycle

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Initialize Window
    window = UIWindow(frame: UIScreen.main.bounds)

    // Configure Window
    window?.rootViewController = appCoordinator.rootViewController

    // Make Key and Visible
    window?.makeKeyAndVisible()

    // Start Coordinator
    appCoordinator.start()

    return true
}

Build and run the application to see the result. You should now see a table view listing a collection of quotes.

What Did We Gain?

You may be wondering what we gained by introducing a coordinator to the project. It may seem as if we only added complexity to the project. The benefits are small at the moment, but this will change once we continue refactoring the project.

Let's start with the quotes view controller. The quotes view controller in the main storyboard is no longer tightly coupled to a navigation controller. This increases the reusability of the QuotesViewController class.

Even though the application(_:didFinishLaunchingWithOptions:) method has gained a few lines of code, there is an improvement. The application's launch sequence is now controlled by the application coordinator. It decides which view controller is presented on launch. It's straightforward to replace the root view controller of the application window or the root view controller of the navigation controller with a different view controller.

There's another important benefit. The coordinator is responsible for instantiating the view controllers it presents. That will be a recurring pattern in this series. View controllers are no longer instantiated by other view controllers or automatically instantiated by UIKit when a segue is triggered. That change will result in more control and flexibility.

What's Next?

We continue refactoring the project in the next episode. Before we refactor the quote and settings view controllers, I introduce a convenient technique to easily and elegantly instantiate view controllers from a storyboard.

Download Your Free Copy of
The Missing Manual
for Swift Development

The Guide I Wish I Had When I Started Out

Join 20,000+ Developers Learning About Swift Development

Download Your Free Copy
Next Episode "Instantiating View Controllers From a Storyboard"