Part 2 in a series on “fixing” Model-View-Controller:
In order to fix the encapsulation violation we saw earlier, we need to understand a pretty simple principle:
In general, a view controller should manage either sequence or UI, but not both.
A view controller that manages sequence is one that I jokingly call a “Manager View Controllers” because 1) “manager” and “controller” aren’t overloaded enough already and 2) it still has the acronym “MVC”. In reality, this is a variation on the “Coordinator” pattern that has captured some of our imagination.
The idea behind the Coordinator pattern (or flow controllers or whatever you call them) is that, in order to maintain encapsulation, you need a higher-level object to decide what comes next. It “controls the flow” in your app.
Where I tend to diverge from the “traditional” flow controller implementation is that I believe that these sorts of controllers should really just be view controllers higher up in the parent view controller chain. This saves you from having to hack a new kind of responder chain object in to
UIViewController, and it means you don’t end up with a third parallel hierarchy of control to maintain in sync with the other two (the View hierarchy and the view controller hierarchy).
Using container view controllers as sequence “coordinators” makes a whole lot of things really easy. For example, consider a screen where you want to load a piece of remote content. But while it’s loading, you want a spinner to show up. If the content fails to load, you want an error screen and a “try again” button.
Implementing this as a single view controller would leave you in a place where you’re on the road to Massive View Controller. You’d have a view controller that’s probably hitting the network and then trying to rationalize which of the three different states it should be in (Error, Loading, or Success), then is responsible for making sure the right set of UI elements are visible and getting the model information into the proper set of out outlets.
Instead, abstract out the sequence from the UI. The sequence is the owner of the flow between the three states, and each of the states is its own view controller.
ShowRemoteContentViewController (the owner of the sequence) has an empty view, and it embeds the proper content view controller depending on which state it should be in:
- Show the
ErrorViewControllerif your networking object reported back an error. The
ErrorViewControllerdelegates back out when the user taps on a “Try Again” button, which causes the delegate (the
ShowRemoteViewController) to transition to the loading state while hitting the network
LoadingViewControlleris an empty view controller that shows a loading indicator. It is literally zero lines of code, unless you want a simple
init()method, in which case it is 4.
MessageViewControlleris the view controller that handles the “success” state. It basically populates its UI from the injected model object, and delegates back out when the user taps a “Load Another” button.
A simple iOS project (Xcode 9.1, Swift 4) showing this in action is available here: iOS Architectures.zip.
We’ve taken something that was really complicated (a single object managing UI and state and flow) and turned it in to something eminently understandable; no view controller is longer than 100 lines of code long. Each one is isolated from everything else. A good use of delegation means that testing is trivial: we load up the UI, fake a tap on the button, and assert that the delegate method is invoked. There’s almost no cognitive load to understand any individual view controller. Even the “more complicated”
ShowRemoteContentViewController is simple, because it’s just flipping back and forth between a couple of different child view controllers in reaction to some delegate method invocations or the network loading. It’s all really simple, and there are no violations of encapsulation.
The same pattern holds true when you’re dealing with more structured UI. If we imagine now a list UI, where tapping a list item brings up a detail view, we can apply this same principle.
First off, we don’t want the list knowing about the detail view controller or how to show it. So, we simply make it delegate back out to someone else what the user is intending to do. The list UI receives model objects from “the outside”, so that’s what it should send back. Therefore, in our implementation of
didSelectRowAtIndexPath:, we simply translate the index path in to the corresponding model object, and then message the delegate what the user intention was:
listViewController:userDidSelectModelObject: → “The user wants to look at this model object”.
So, who then is the delegate? You could argue that it might be the direct
parentViewController, but in this case, the parent is likely a
UINavigationController is already in charge of maintaining a stack of view controllers with its own UI (the navigation bar), so this doesn’t seem like a good candidate. Instead, let’s put the
UINavigationController inside a container view controller, which we’ll call the
ListFlowViewController creates and embeds the plain
UINavigationController in itself and gives it the root screen (the list view controller). Then, it makes itself the delegate of the list, because it’s the
ListFlowViewController that created the list and (likely) knows what the model objects mean. Then when the user selects an item in the list, the
ListFlowViewController receives the corresponding model object, knows how to turn it into the detail screen, and gives that screen to the
UINavigationController to push. We again preserve proper encapsulation principles.
This same pattern also holds true on iPad (or regular width phones) where you deal with
UISplitViewController. Having a container view controller own the split view means you have a natural place for the “master” view controller to message about the user intent, and handle how to appropriate display the detail view controller. This same container view controller could also decide to entirely eschew a split view controller if the app transitions back to a compact width size class.
The huge advantage of this approach is that system features come free. Trait collection propagation is free. View lifecycle callbacks are free. Safe area layout margins are generally free. The responder chain and preferred UI state callbacks are free. And future additions to
UIViewController are also free.
In exchange, you have to suffer through cleaner code, smaller view controllers, and a few more delegate protocols, which unfortunately just make your code more isolated and testable. How on earth will you survive?? 🙃