Simplify your navigation and your view controllers
Using the coordinator pattern in iOS apps lets us remove the job of app navigation from our view controllers, helping make them more manageable and more reusable, while also letting us adjust our app's flow whenever we need.
This is part 1 in a series of tutorials on fixing massive view controllers:
View controllers work best when they stand alone in your app, unaware of their position in your app’s flow or even that they are part of a flow in the first place. Not only does this help make your code easier to test and reason about, but it also allows you to re-use view controllers elsewhere in your app more easily.
In this article I want to provide you with a hands-on example of the coordinator pattern, which takes responsibility for navigation out of your view controllers and into a separate class. This is a pattern I learned from Soroush Khanlou – folks who’ve heard me speak will know how highly I regard Soroush and his work, and coordinators are just one of many things I’ve learned reading his blog.
That being said, before I continue: I should emphasize this is the way I use coordinators in my own apps, so if I screw something up it’s my fault and not Soroush’s!
Prefer video? The screencast below contains everything in this article and more – subscribe to my YouTube channel for more like this.
SPONSORED Transform your career with the iOS Lead Essentials. This Black Friday, unlock over 40 hours of expert training, mentorship, and community support to secure your place among the best devs. Click for early access to this limited offer and a free crash course.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Let’s start by looking at code most iOS developers have written a hundred or more times:
if let vc = storyboard?.instantiateViewController(withIdentifier: "SomeVC") {
navigationController?.pushViewController(vc, animated: true)
}
In that kind of code, one view controller must know about, create, configure, and present another. This creates tight coupling in our application: you have hard-coded the link from one view controller to another, so and might even have to duplicate your configuration code if you want the same view controller shown from various places.
What happens if you want different behavior for iPad users, VoiceOver users, or users who are part of an A/B test? Well, your only option is to write more configuration code in your view controllers, so the problem only gets worse.
Even worse, all this involves a child telling its navigation controller what to do – our first view controller is reaching up to its parent and telling it present a second view controller.
To solve this problem cleanly, the coordinator pattern lets us decouple our view controllers so that each one has no idea what view controller came before or comes next – or even that a chain of view controllers exists.
Instead, your app flow is controlled using a coordinator, and your view communicates only with a coordinator. If you want to have users authenticate themselves, ask the coordinator to show an authentication dialog – it can take care of figuring out what that means and presenting it appropriately.
The result is that you’ll find you can use your view controllers in any order, using them and reusing them as needed – there’s no longer a hard-coded path through your app. It doesn’t matter if five different parts of your app might trigger user authentication, because they can all call the same method on their coordinator.
For larger apps, you can even create child coordinators – or subcoordinators – that let you carve off part of the navigation of your app. For example, you might control your account creation flow using one subcoordinator, and control your product subscription flow using another.
If you want even more flexibility, it’s a good idea for the communication between view controllers and coordinators to happen through a protocol rather than a concrete type. This allows you to replace the whole coordinator out at any point later on, and get a different program flow – you could provide one coordinator for iPhone, one for iPad, and one for Apple TV, for example.
So, if you’re struggling with massive view controllers I think you’ll find that simplifying your navigation can really help. But enough of talking in the abstract – let’s try out coordinators with a real project…
Start by creating a new iOS app in Xcode, using the Single View App template. I named mine “CoordinatorTest”, but you’re welcome to use whatever you like.
There are three steps I want to cover in order to give you a good foundation with coordinators:
Like I said above, it’s a good idea to use protocols for communicating between view controllers and coordinators, but in this simple example we’ll just use concrete types.
First we need a Coordinator
protocol that all our coordinators will conform to. Although there are lots of things you could do with this, I would suggest the bare minimum you need is:
start()
method to make the coordinator take control. This allows us to create a coordinator fully and activate it only when we’re ready.In Xcode, press Cmd+N to create a new Swift File called Coordinator.swift. Give it this content to match the requirements above:
import UIKit
protocol Coordinator {
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get set }
func start()
}
While we’re making protocols, I usually add a simple Storyboarded
protocol that lets me create view controllers from a storyboard. As much as I like using storyboards, I don’t like scattering storyboard code through my project – getting all that out into a separate protocol makes my code cleaner and gives you the flexibility to change your mind later.
I don’t recall where I first saw this approach, but it’s straightforward to do. We’re going to:
Storyboarded
.instantiate()
, which returns an instance of whatever class you call it on.instantiate()
that finds the class name of the view controller you used it with, then uses that to find a storyboard identifier inside Main.storyboard.This relies on two things to work.
First, when you use NSStringFromClass(self)
to find the class name of whatever view controller you requested, you’ll get back YourAppName.YourViewController. We need to write a little code to split that string on the dot in the center, then use the second part (“YourViewController”) as the actual class name.
Second, whenever you add a view controller to your storyboard, make sure you set its storyboard identifier to whatever class name you gave it.
Create a second new Swift file called Storyboarded.swift, then give it the following protocol:
import UIKit
protocol Storyboarded {
static func instantiate() -> Self
}
extension Storyboarded where Self: UIViewController {
static func instantiate() -> Self {
// this pulls out "MyApp.MyViewController"
let fullName = NSStringFromClass(self)
// this splits by the dot and uses everything after, giving "MyViewController"
let className = fullName.components(separatedBy: ".")[1]
// load our storyboard
let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
// instantiate a view controller with that identifier, and force cast as the type that was requested
return storyboard.instantiateViewController(withIdentifier: className) as! Self
}
}
We already have a view controller provided by Xcode for this default project. So, open ViewController.swift and make it conform to Storyboarded
:
class ViewController: UIViewController, Storyboarded {
Now that we have a way to create view controllers easily, we no longer want the storyboard to handle that for us. In iOS, storyboards are responsible not only for containing view controller designs, but also for configuring the basic app window.
We’re going to allow the storyboard to store our designs, but stop it from handling our app launch. So, please open Main.storyboard and select the view controller it contains:
Storyboarded
protocol to work.The final set up step is to stop the storyboard from configuring the basic app window:
That’s all our basic code complete. Your app won’t actually work now, but we’re going to fix that next…
At this point we’ve created a Coordinator
protocol defining what each coordinator needs to be able to do, a Storyboarded
protocol to make it easier to create view controllers from a storyboard, then stopped Main.storyboard from launching our app’s user interface.
The next step is to create our first coordinator, which will be responsible for taking control over the app as soon as it launches.
Create a new Swift File called MainCoordinator.swift, and give it this content:
import UIKit
class MainCoordinator: Coordinator {
var childCoordinators = [Coordinator]()
var navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let vc = ViewController.instantiate()
navigationController.pushViewController(vc, animated: false)
}
}
Let me break down what all that code does…
childCoordinators
array to satisfy the requirement in the Coordinator
protocol, but we won’t be using that here.navigationController
property as required by Coordinator
, along with an initializer to set that property.start()
method is the main part: it uses our instantiate()
method to create an instance of our ViewController
class, then pushes it onto the navigation controller.Notice how that MainCoordinator
isn’t a view controller? That means we don’t need to fight with any of UIViewController’s quirks here, and there are no methods like viewDidLoad()
or viewWillAppear()
that are called automatically by UIKit.
Now that we have a coordinator for our app, we need to use that when our app starts. Normally app launch would be handled by our storyboard, but now that we’ve disabled that we must write some code inside AppDelegate.swift to do that work by hand.
So, open AppDelegate.swift and give it this property:
var coordinator: MainCoordinator?
That will store the main coordinator for our app, so it doesn’t get released straight away.
Next we’re going to modify didFinishLaunchingWithOptions
so that it configures and starts our main coordinator, and also sets up a basic window for our app. Again, that basic window is normally done by the storyboard, but it’s our responsibility now.
Replace the existing didFinishLaunchingWithOptions
method with this:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// create the main navigation controller to be used for our app
let navController = UINavigationController()
// send that into our coordinator so that it can display view controllers
coordinator = MainCoordinator(navigationController: navController)
// tell the coordinator to take over control
coordinator?.start()
// create a basic UIWindow and activate it
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = navController
window?.makeKeyAndVisible()
return true
}
If everything has gone to plan, you should be able to launch the app now and see something.
At this point you’ve spent about 20 minutes but don’t have a whole lot to show for your work. Stick with me a bit longer, though – that’s about to change!
Coordinators exist to control program flow around your app, and we’re now in the position to show exactly how that’s done.
First, we need some dummy view controllers that we can display. So, press Cmd+N to create a new Cocoa Touch Class, name it “BuyViewController”, and make it subclass from UIViewController
. Now make another UIViewController
subclass, this time called “CreateAccountViewController”.
Second, go back to Main.storyboard and drag out two new view controllers. Give one the class and storyboard identifier “BuyViewController”, and the other “CreateAccountViewController”. I recommend you do something to customize each view controller just a little – perhaps add a “Buy” label to one and “Create Account” to the other, just so you know which one is which at runtime.
Third, we need to add two buttons to the first view controller so we can trigger presenting the others. So, add two buttons with the titles “Buy” and “Create Account”, then use the assistant editor to connect them up to IBActions methods called buyTapped()
and createAccount()
.
Fourth, all our view controllers need a way to talk to their coordinator. As I said earlier, for larger apps you’ll want to use protocols here, but this is a fairly small app so we can refer to our MainCoordinator
class directly.
So, add this property to all three of your view controllers:
weak var coordinator: MainCoordinator?
While you’re in BuyViewController
and CreateAccountViewController
, please also take this opportunity to make both of them conform to the Storyboarded
so we can create them more easily.
Finally, open MainCoordinator.swift and modify its start()
method to this:
func start() {
let vc = ViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: false)
}
That sets the coordinator
property of our initial view controller, so it’s able to send messages when its buttons are tapped.
At this point we have several view controllers all being managed by a single coordinator, but we still don’t have a way to move between view controllers.
To make that happen, I’d like you to add two new methods to MainCoordinator
:
func buySubscription() {
let vc = BuyViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
func createAccount() {
let vc = CreateAccountViewController.instantiate()
vc.coordinator = self
navigationController.pushViewController(vc, animated: true)
}
Those methods are almost identical to start()
, except now we’re using BuyViewController
and CreateAccountViewController
instead of the original ViewController
. If you needed to configure those view controllers somehow, this is where it would be done.
The last step – the one that brings it all together – is to put some code inside the buyTapped()
and createAccount()
methods of the ViewController
class.
All the actual work of those methods already exists inside our coordinator, so the IBActions become trivial:
@IBAction func buyTapped(_ sender: Any) {
coordinator?.buySubscription()
}
@IBAction func createAccount(_ sender: Any) {
coordinator?.createAccount()
}
You should now be able to run your app and navigate between view controllers – all powered by the coordinator.
I hope this has given you a useful introduction to the power of coordinators:
I have a second article that goes into more detail on common problems people face with coordinators – click here to read my advanced coordinators tutorial.
If you’re keen to learn more about design patterns in Swift, you might want to look at my book Swift Design Patterns.
And finally, I want to recommend once again that you visit Soroush Khanlou’s blog, khanlou.com, because he’s talked extensively about coordinators, controllers, MVVM, protocols, and so much more:
SPONSORED Transform your career with the iOS Lead Essentials. This Black Friday, unlock over 40 hours of expert training, mentorship, and community support to secure your place among the best devs. Click for early access to this limited offer and a free crash course.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.