Controller Hierarchies
Occasionally an activity within your app requires multiple screens. Consider uploading a photo to Instagram: pick a photo, pick the filter, and add a description. When the user taps a photo in the first screen, it might look like:
func userDidPickPhoto(_ photo:Photo){
let filter = FilterViewController()
filter.photo = photo
navigationController?.push(filter, animated:true)
}
This is problematic. First, it's bad form for a view controller to "reach up," mutating its parent's state. Further, what if you embed this view controller in a totally custom container controller on iPad, and navigationController
is nil
?
Second, each screen shouldn't know about the steps that come before or after it. If a product manager decides to reorder the steps in the flow, you have to dig through a bunch of disconnected controllers. By making our photo picker only concerned with photo-picking, you can reuse it for picking a new profile picture.
One solution batted around is to create a PhotoCreationCoordinator
which manages the multi-screen flow. It's not a view controller, but an ordinary object that manages the navigation stack, bossing the view controllers around.
In this system, you maintain a coordinator tree that lives a step-above the view controller heirarchy. You start at a central AppCoordinator
, which maintains an array of child coordinators, which might have their own sub-coordinators, etc.
While the intention is good, we can do better with a fraction of the complexity.
The Tradeoff
You can't really mix and match coordinators with traditional UIKit. The coordinator tree must be your "source of truth." This adds complexity to simple situations. Consider presenting a one-and-done share sheet:
func didTapShareProfile(_ sender:Any){
let coordinator = ShareProfileCoordinator(rootViewController:self, delegate:self)
mainCoordinator.childCoordinators.append(coordinator)
coordinator.shareProfile()
}
Every interaction, no matter how small, requires the involvement of the coordinator. This leads to unneccessary boilerplate and indirection that impacts clarity. Looking at this code, it's not obvious that shareProfile()
sends you to ShareSheetController.
This is how complexity creeps up on smart developers. Not with a single bad design, but dozens of tiny abstractions that sound good on paper but add up to nothing most of the time. Continue down this path and before you know it every screen in your app requires five boilerplate models and a UML diagram.
Refactoring with Vanilla Design
Let's stick to tools within UIKit, starting with the trivial problem.
navigationController?.push(filter, animated:true)
In iOS 8, UIKit added the show method to UIViewController for navigation dispatch.
You use this method to decouple the need to display a view controller from the process of actually presenting that view controller onscreen. Using this method, a view controller does not need to know whether it is embedded inside a navigation controller or split-view controller.
Just change it to:
self.show(filter, sender:self)
With the help of targetViewController(forAction:sender:) this approach scales to any bespoke navigation you dream up.
Now on to multi-screen flows. Let's lean-in to the idea that view controllers are the boundary between activities.
We'll create a parent controller, PhotoCreationViewController.
Through View Controller Containment, it embeds a single child UINavigationController
. Similar to a coordinator, our controller hosts the complex logic of our flow, pushing and popping view controllers on the nav controller.
Each step communicates with the PhotoCreationViewController
through delegation:
protocol PhotoPickerViewControllerDelegate:class {
func photoPicker(_ controller:PhotoPickerViewController, picked photo:Photo)
}
// Initializer and child controller setup omitted
class PhotoCreationViewController:UIViewController, PhotoPickerViewControllerDelegate {
var delegate:PhotoCreationViewControllerDelegate?
lazy var picker = PhotoPickerViewController(delegate:self)
lazy var creationNav = UINavigationController(rootViewController:self.picker)
func photoPicker(_ controller:PhotoPickerViewController, picked photo:Photo) {
let filterController = FilterViewController()
filterController.photo = photo
self.creationNav.show(filterController, sender:self)
}
}
Compare and contrast at the call site. Here's the coordinator:
func userDidTapCompose(_ sender:Any){
let coordinator = PhotoCreationCoordinator(rootViewController:self.rootViewController, delegate:self)
self.childCoordinators.append(coordinator)
coordinator.start()
}
Here's our vanilla design:
func userDidTapCompose(_ sender:Any){
let controller = PhotoCreationViewController(delegate:self)
self.present(controller, animated:true)
}
Pick any iOS developer at random and they'll know how to call our API. The details of our activity are totally encapsulated-- the caller doesn't know whether we're displaying a single screen or a complex maze. Most importantly, it keeps simple activities simple. There's no coordinator tree to maintain, and it's immediately obvious PhotoCreationViewController
is about to take ownership.
Keep in mind that complex navigation logic doesn't need to live within our parent view controller. You could create a CreationFlow
model, and let the parent view controller function only as glue.
// Within PhotoCreationViewController
func photoPicker(_ controller:PhotoPickerViewController, picked photo:Photo) {
let nextStep = self.creationFlow.pickedPhoto(photo)
let controller = self.makeController(step:nextStep)
self.creationNav.show(controller, sender:self)
}
Unlike a coordinator, this new model's only responsibility is flow logic. It knows nothing about the view controllers that are instanced. This simplifies testing and I could even reuse it in a Mac app.
But in simple situations that's overkill. In the Instagram example, I'd just inline it in the view controller for clarity.
Another win with parent view controllers is that they participate in the responder chain. Earlier we used delegation to wire up our controllers, because I wanted to be explicit, but sometimes introduces a bunch of needless boilerplate. Imagine if every screen in your app has a button that opens the photo uploader. Rather than repeat boilerplate didRequestPhotoUpload()
in your view controller's protocol, just send a photoUpload
message to your first responder. It will bounce up the responder chain until one of the parent view controllers takes responsibility.
Presentation Yields Control
I think most developers confuse the appearance of presentation with the concept. They think it's only about making your view controller take over the screen. Apple backed away from this by iOS 6, when they deprecated presentModalViewController
in favor of the more general presentViewController
. Presentation is really about yielding control to a sub-activity.
Say Instagram wants to warn the current user that they're about to view mature content; when you tap a censored photo, it presents a modal asking the user for their date of birth, followed by a second screen with a checkbox. When that's done, it should return to your home timeline and uncensor the photo.
It would be annoying for our prompt to take over the entire screen, preventing you from visiting other tabs. It should only interrupt your home timeline feed. While presentation defaults to full-screen, it's easy to override.
On your home timeline view controller, set definesPresentationContext
to true
. On your MatureWarningViewController
, change modalPresentationContext
from fullScreen
to overCurrentContext
. Now when you present it will only take over your home timeline view controller, leaving your tab bar unscathed.
Don't want your presented view controller to float up from the bottom? There are APIs to customize it however you like. With some elbow grease you can even make it look like your presented controller was pushed onto the UINavigationController
stack. (This is bad UX. Consider the extra work penance.)
Examples in Halide
Onboarding
When you first launch Halide, we show an onboarding screen that introduces the interface and then prompts for camera and photo library permissions.
Here's how you call it:
let onboarding = OnboardingViewController(delegate: self)
self.present(onboarding, animated: true)
The OnboardingViewController
is just a UIViewController
. It embeds a UIPageViewController
which shows the user manual. It could just as easily be a UINavigationController
. OnboardingViewController
is the dataSource
for the page view controller, vending it ManualViewController
pages instanced via storyboards.
Photo Reviewer
Our photo reviewer functions just like the built-in camera app. When you open it, we default to a full size view of the last photo. If you tap the back button, we show a grid of the rest of the photos in your library.
From the camera, you instance it with:
let reviewer = PhotoReviewViewController(delegate:self, photoStore:self.photoStore)
self.present(reviewer, animated: true)
PhotoReviewViewController
is just another UIViewcontroller.
It embeds a UINavigationController.
Within that, the first controller on the stack is PhotoListViewController
for the grid, and then PhotoDetailsViewController
for the full-sized photo.
Because I didn't subclass UINavigationController,
implementation details don't leak to the caller. Plus there's no issue with the delegate
property being taken by the superclass.
Asset Sharing
Sometimes sharing a photo in Halide is a multi-step process. For a simple JPEG, we just present a UIActivityViewController
. For a RAW photo, we ask if you'd like it converted to JPEG via a UIAlertViewController
action sheet, and then we present the activity view controller in the format you picked.
In the first version of the app, I created a ShareFlow
class that functions similar to a coordinator, which presents and dismisses each prompt on top of the current view controller.
self.shareFlow = ShareFlow(rootViewController:self, photo:photo)
self.shareFlow?.start()
It got the job done, but always felt wrong. This other object shouldn't be allowed to mess with the state of current view controller. The updated code looks like this:
let shareController = PhotoShareViewController(photo:photo)
self.present(shareController, animated:true)
That PhotoShareViewController
just loads an empty, transparent view. Its only purpose is to host the multi-step logic, and act as a target that presents the action sheet and activity sheet. The view controller asking to share an asset is no longer mutated behind its back, and no longer needs a shareFlow
property.
While it's a little weird to have a "viewless view controller," it's much less weird than the alternative.
Simplicity Matters
I'm sure you can come up with a flow that doesn't neatly fit into view controller heirarchies. I won't argue they solve every problem. Just most of them.
Stop me if you've heard this. A new engineer joins a project and says, "This is garbage!" They replace it with a system that looks better, but as time goes on, they discover all these quirks and edge cases. Over time they either reinvent the first system, or trade one set of problems for another.
Every design has a tradeoff. UIKit is optimized for iOS interface conventions. I believe most apps follows these conventions most of the time. Designing a more complex system to solve every edge case makes the common case harder.
Engineers fall into a trap where they think they can solve any problem by just adding more tools to their toolbox, collecting design patterns like Pokemon. Most blogs about design patterns fail to capture intent, so the engineer can't generalize them. Next then you know, everything is an abstract factory and you're using the visitor pattern instead of a simple for-loop.
It's tempting to think complex problem are solved by complex architecture. History shows the opposite. From RFC1958, "Architectural Principles of the Internet":
Many members of the Internet community would argue that there is no architecture ... However, in very general terms, the community believes that the goal is connectivity, the tool is the Internet Protocol, and the intelligence is end to end rather than hidden in the network.
From RFC3439, "Some Internet Architectural Guidelines and Philosophy":
In short, the complexity of the Internet belongs at the edges, and the IP layer of the Internet should remain as simple as possible.
It's easy to build a complex system by piling on complexity. Instead, strive to build complex things on top of simple systems.