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.
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.
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.
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.
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.