This is a post in a series on Advanced Coordinators. If you haven’t read the original post or the longer follow-up, make sure to check those out first. The series will cover a few advanced coordinator techniques, gotchas, FAQs, and other trivia.
When working with coordinators, all flow events should travel through the coordinator. Any time a view controller intends to change flow state, it informs the coordinator, and the coordinator can handle side effects and make decisions about how to proceed.
There is one glaring exception to this rule: when a navigation controller navigates “back”. That back button is not a traditional button, so you can’t add handlers to it to send messages up to the coordinator. Further, its associated behavior is performed directly by the navigation controller itself. If you need to do any work in a coordinator when a view controller is dismissed, you need some way to hook into that behavior.
While there are other less common examples, the primary use case is when you have a sub-flow that takes place entirely within the context of another navigation controller. Coordinators typically own one navigation controller exclusively, but sometimes, a subset of the flow with in a navigation controller stack needs to be broken out into its own coordinator, usually for reuse purposes. That separate coordinator shares the navigation controller with its parent coordinator. If the user enters the child coordinator (entering the sub-flow) and then taps the back button, that child coordinator needs to be cleaned up. If it’s not cleaned up, that coordinator’s memory will effectively be leaked. Further, if they enter that flow a second time, we might have two of the same coordinator, potentially reacting to similar events and executing code twice.
So, we need a way to know that the navigation’s back button has been tapped. The UINavigationControllerDelegate
is the easiest way to get access to this event. (You could subclass or swizzle, but let’s not.)
There are a few ways to use this delegate to solve this problem, and I’d like to highlight two of them. The first is Bryan Irace’s approach to tackling this problem. He makes a special view controller called NavigationController
that allows you to push coordinators in addition to pushing view controllers.
I’ll elide some of the details and give an overview of the approach, but if you want a full details, I recommend reading his whole post. The main thing to note in his code is:
final class NavigationController: UIViewController {
// ...
private let navigationController: UINavigationController = //..
private var viewControllersToChildCoordinators: [UIViewController: Coordinator] = [:]
// ...
}
This shows the way that this class works. When you add a new coordinator to this class, it creates an entry in this dictionary. The entry maps the root view controller of a coordinator to the coordinator itself. Once you have that, you can conform to the UINavigationControllerDelegate
.
extension NavigationController: UINavigationControllerDelegate {
func navigationController(navigationController: UINavigationController,
didShowViewController viewController: UIViewController, animated: Bool) {
// ...
}
}
At that point, if the popped view controller is found in the coordinator dictionary, it will remove it, allowing it to correctly deallocate.
There’s a lot to like about this approach. Coordinator deallocation is handled automatically for you, when you use this class instead of a UINavigationController
. However, it comes with a few downsides, as well. My primary concern is that the NavigationController
class, which is a view controller, knows about and has to deal with coordinators. This is tantamount to a view having a reference to a view controller.
I think there are some goopy bits on the inside of UIKit where views know about their view controllers. I haven’t seen the source code, but the stack trace for -viewDidLayoutSubviews
suggests that there’s some voodoo going on here. Sometimes, components in a library may be coupled together more tightly, in order to make the end user’s code cleaner. This is the tradeoff that Bryan is making here.
If you don’t want to make that tradeoff, you can bring the navigation controller delegate methods to the parent coordinator, where they can live with all the other flow events. This is my preference. By making the coordinator into the delegate of the navigation controller, you can maintain the structure of the coordinator: namely that it is the parent of the navigation controller. When you get the delegate messages that a view controller was popped off, you can manually clean up any coordinators that need to be dealt with.
extension Coordinator: UINavigationControllerDelegate {
func navigationController(navigationController: UINavigationController,
didShowViewController viewController: UIViewController, animated: Bool) {
// ensure the view controller is popping
guard
let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from),
!navigationController.viewControllers.contains(fromViewController) else {
return
}
// and it's the right type
if fromViewController is FirstViewControllerInCoordinator) {
//deallocate the relevant coordinator
}
}
}
This approach is slightly more manual, with the up- and downsides that come with that: more control and more boilerplate. If you don’t like the direct type check, you can replace it with a protocol.
You’ll also need to re-enable the interactivePopGestureRecognizer
by conforming to UIGestureRecognizerDelegate
and returning true
for the shouldRecognizeSimultaneouslyWithGestureRecognizer
delegate method.
Both approaches are good ways of handling decommisioned coordinators and ensuring that they correctly deallocate, and these techniques are crucial for breaking out your subflows out into their own coordinators so they can be reused.
Update: Ian MacCallum provides another approach to this problem. He essentially provides a onPop
block for a weak coupling between the coordinator and navigation controller (which he wraps up in an object called a Router
). It’s a good approach.
This is a post in a series on Advanced Coordinators. If you haven’t read the original post or the longer follow-up, make sure to check those out first. The series will cover a few advanced coordinator techniques, gotchas, FAQs, and other trivia.
When splitting up the responsibilities of a view controller, I do a curious thing. While I leave reading data (for example, a GET request, or reading from a database or cache) in the view controller, I move writing data (such as POST requests, or writing to a database) up to the coordinator. In this post, I’ll explore why I separate these two tasks.
Coordinators are primarily in charge of one thing: flow. Why sully a beautiful single responsibility object with a second responsibility?
I make this distinction because I think flow is the wrong way to think about this object’s responsibility. The correct responsibility is “handle the user’s action”. The reason to draw this distinction is so that the knowledge of when to “do a thing” (mutate the model) and when to “initiate a flow step” can be removed from the view controller. I don’t want a view controller to know what happens when it passes the user’s action up to a coordinator.
You can imagine a change to your app’s requirements that would make this distinction clear. For example, let’s say you have an app with an authentication flow. The old way the app worked was that the user typed their username and password into one screen, and then the signup request could be fired. Now, the product team wants the user to be able to fill out the profile on the next screen, before firing off the signup request. If you keep model mutation in the view controller and the flow code in the coordinator, you’ll have to make a change to both the view controller and the coordinator to make this work.
It gets even worse if you’re A/B testing this change, or slowly rolling it out. The view controller would need an additional component to tell it how to behave (not just how to present its data), which means either a delegate method back up to the coordinator or another object entirely, which would help it decide if it should call inform the coordinator to present the next screen or if it should just post the signup call itself.
If you keep model mutation and flow actions together, the view controller doesn’t have to change at all. The view controller gets to mostly act like it’s in the view layer, and the coordinator, with its fullness of knowledge, gets to make the decision about how to proceed.
Another example: imagine your app has a modal form for posting a message. If the “Close” button is tapped, it should dismiss the modal and delete the draft from the database (which, let’s say, is saved for crash protection). If your designer decides that they want an alert view that asks “Are you sure?” before deleting the draft, your flow and your database mutation are again intertwined. Showing the dialog is presenting a view controller, which is a flow change, and deleting an item from the database is a model mutation. Keeping these responsibilities in the same place will ease your pain when you have to make changes to your app.
One additional, slightly related note: the coordinator’s mutative effect on the model should happen via a collaborator. In other words, your coordinator shouldn’t touch URLSession
directly, nor any database handle, like an NSManagedObjectContext
. If you like thinking about view models, you might consider a separation between read-only view models (which you could call a Presenter
) and write-only view models (which you could call an Interactor
or a Gateway
). Read-only view models can go down into the view controller, and write-only view models stay at the coordinator level.
The line between model mutation and flow step is thinner than you’d expect. By treating those two responsibilities as one (responding to user action), you can make your app easier to change.
This article is also available in Chinese.
This the going to be the first post in a series on Advanced Coordinators. If you haven’t read the original post or the longer follow-up, make sure to check those out first. The series will cover a few advanced coordinator techniques, gotchas, FAQs, and other trivia. Let’s dig in.
I’m often asked how to migrate an app from using storyboards or per-view controller code-based flow to an app using coordinators. When done right, this refactoring can be done piecemeal. You will continuously be able to deploy your app, even if the refactoring isn’t complete.
To acheive this, the best thing to do is start from the root, which for coordinators is called the “app coordinator”. The app delegate holds on to the app coordinator, which is the coordinator that sets up all the view controllers for your app’s launch.
To understand why we start from the root of the app, consider the opposite. If we started from some leaf flow (like, say, a CheckoutCoordinator
), then something needs to maintain a strong reference to the coordinator so that it doesn’t deallocate. If the coordinator deallocates, none of its code can run. So, deep in an app, if we create a coordinator, something will have to hold on to it.
There are two ways to prevent this deallocation. The first option is to make a static reference. Because there will likely only ever be one CheckoutCoordinator
, it’ll be easy to stuff it in to a global variable. While this works, this isn’t an ideal choice, since globals hinder testability and flexibility. The second option is to have the presenting view controller maintain a reference to the coordinator. This will force a little complexity onto the presenting view controller, but will allow us to remove more complexity from all the view controllers that are managed by that coordinator. However, this relationship is fundamentally flawed. View controllers are usually “children” to coordinators, and when programming, children shouldn’t know who their parents are. I would liken this to a UIView
having a reference to a UIViewController
: it shouldn’t happen.
If you have a situation where you’ve decided that you absolutely must start with some child flow in your app, then you can make it work with one of the two methods above. However, if you have the power to start from the root, that’s my recommendation.
One other benefit to starting from the root is that the authentication flow is often close to the root of the app. Authentication is a great flow to isolate away into its own object, and a nice testbed for proving coordinators in your app.
Once you’ve moved the root view controller of the app to your AppCoordinator
, you can commit/pull request/code review/etc the code. Because every other view controller transition continues to work, the app will still be fully functional in this halfway state. At this point, working one-by-one, you can start to move more view controller transitions over to the coordinator. After each “flow step” is moved to your coordinator, you can commit or make a pull request, since the app will continue to work. Like the best refactorings, each of these steps are mostly just moving code around, sometimes creating new coordinators as needed.
Once all of your transitions have been moved over to coordinators, you can do further refactorings, like separating iPhone and iPad coordinators into individual objects (instead of one coordinator that switches on some state), making child flows reusable, and better dependency injection, all of which are enabled by your new architecture.