iOS Coordinators
In our apps, we use the MVVM (Model-View-ViewModel) approach. This concept manages the interaction within a view controller, but who manages the view controllers?
The Coordinator!
More precisely, multiple coordinators manage almost all view controllers in our apps. Coordinators are arranged in a tree structure, each coordinator is responsible for certain UI flow (a bunch of view controllers) in the app.
Let start with a simple example. A registration flow with a single input per screen.
This is an implementation of a registration coordinator which manages our registration flow. Each screen enters one value and adds information to our user object to complete the registration. The RegistrationCoordinator
is a subclass of NavigationCoordinator
. A NavigationCoordinator
manages a UINavigationController
. So our RegistrationCoordinator
is completely in charge of the order and presentation of its view controllers. View controllers do not present the next view controller. View controllers are not responsible for presenting other view controllers, this is the job and responsibility of the coordinator. The view controllers only report to the coordinator (using a closure or delegate) if the user completed a task such as entering an email address.
class RegisterCoordinator: NavigationCoordinator {
enum Step {
case email
case password
case firstName
case lastName
}
var didFinish: ((User) -> Void)?
var didCancel: (() -> Void)?
let steps: [Step] = [ .email, .password, .firstName, .lastName]
func start() {
showNext(step: .email)
}
func showNext(step: Step) {
let viewController = createViewController(with: step)
push(viewController, animated: true)
}
// ...
}
As mentioned before, coordinators are organised in a hierarchy. At the root, we have an AppCoordinator
which manages all user flows in our app. A coordinator can have multiple child coordinators, which represent features of the app such as the RegistrationCoordinator
shown above.
We have two kinds of base coordinators: NavigationCoordinator
and TabBarCoordinator
.
Reusable View Controllers
In our approach view controllers have a well-defined task and interface (= input & output). For example, the task of a TextInputViewController
is simple: it takes a text input from the user and reports it by calling the didEnterText
closure.
class TextInputViewController: UIViewController {
// MARK: - Output
var didEnterText: ((String) -> Void)?
// MARK: - Input
var viewModel: TextInputViewModel!
class func create(viewModel: TextInputViewModel) -> Self {
let viewController = UIStoryboard(.onboarding).instantiateViewController(self)
viewController.viewModel = viewModel
return viewController
}
// ...
}
The coordinator which uses the TextInputViewController
implements this closure and acts accordingly. In our registration example the coordinator would present the next step or finish the registration. That view controller does not know which view controller will be presented next as this is the responsibility of the coordinator. That way the TextInputViewController
can be easily reused on multiple occasions across the app in different user flows.
Recap
At first, this concept may look a little over-engineered and complex. And to be honest, for a small and simple app it probably is. But in our experience simple apps usually do not stay simple, they grow! Using this approach at the start of a project eases the way for future updates. The clear structure and delegation of responsibility from the view controller to the coordinator makes a project easier to maintain and simpler to collaborate on well-defined UI flows or reusable view controllers.
Check out our coordinator implementation in our iOS Starter
project on Github: https://github.com/allaboutapps/ios-starter