When working on iOS app, now more than ever one should avoid having view controllers pushing other view controllers around.
Why?
The problem
Modern apps need to support multiple presentation types for same VC, e.g., on iPhone you push a new view controller but on iPad you either embed it as a containment view controller or show it from popover.
Also in many cases you might want to reuse the same ViewController in different scenario, e.g. Image Picking View Controller can appear in multiple places and be presented differently depending on it’s context.
ViewControllers should be implemented in such a way that they don’t depend on their presentation style, after all this is one of the reasons we have size classes.
Let’s assume that you do naive version of presenting view controller from other VC’s / ViewModels, you will end up with a bunch of if statements and your code will be one big spaghetti of conditions.
Since my work as a Consultant often involved reviewing projects and helping teams establish cleaner solutions.
I’ve seen a lot of spaghetti code, this is not even close to the worst I’ve seen but it’s pretty bad already:
func doneButtonTapped() {
let vc = NextViewController(prepareNeccesaryState())
if Device.isIPad() {
navigationController.pushViewController(vc, animated: true, completion: nil)
} else {
var nav = UINavigationController(rootViewController: vc)
nav.modalPresentationStyle = UIModalPresentationStyle.Popover
var popover = nav.popoverPresentationController
popoverContent.preferredContentSize = CGSizeMake(500, 600)
popover.delegate = self
popover.sourceView = self.view
popover.sourceRect = CGRectMake(100, 100, 0, 0)
presentViewController(nav, animated: true, completion: nil)
}
}
The naive approach has lots of issues:
- Unnecessary dependencies - one view controller doesn’t need to know about the other
- Poor reusability
- Spaghetti code in case your app needs different presentation mode - you end up with a lot of control flow.
- Singletons are tempting as they make it easier to write your code.
- Testing is harder, your VC/VM will have a lot of side-effects.
How can we fix it?
Cleaning up your ViewControllers / ViewModels
This can be applied to MVVM, MVC and many other common patterns. When I talk about VC/VM, just think about the one you are using right now.
Let’s start by getting rid of all dependencies, instead of hardcoding related controllers, use Delegate or block based interface
class MyViewController {
let onDone = (Void -> Void)?
func doneButtonTapped() {
onDone?(prepareNeccesaryState())
}
}
ViewController / ViewModel should:
- Not reference other screens
- Doesn’t use any of UIKit presentation classes or methods like UINavigationController or presentViewController
- Has interface that other objects can register to known when the functionality is firing e.g. Delegate / Block
- Not reference any singletons, you’ll see in a second how this requirement is easy to fill with my approach.
At this point we already improved testability, as we can now test if our interfaces are triggered without having side-effects, pseudo-code:
let vc = createVC()
var executed = false
vc.onDone = {
executed = true
}
//! add code here to trigger done state
expect(executed).toEventually(beTruthy())
But how do we coordinate our app view controllers?
Introducing FlowController’s
A FlowController is a simple object that will manage part of your app, or as I like to think about it ‚a subset of use cases’.
Three main roles of FlowController are:
- Configuring view controller for specific context - e.g. different configuration for an Image Picker shown from application Create Post screen and different when changing user avatar
- Listening to important events on each ViewController and using that to coordinate flow between them.
- Providing view controller with objects it needs to fulfill it’s role, thus removing any need for singletons in VC’s
func configureProgramsViewController(viewController: ProgramsViewController, navigationController: UINavigationController) {
viewController.state = state
viewController.addProgram = { [weak self] barButton in
guard let strongSelf = self else { return }
let createVC = R.storyboard.createProgram.initialViewController!
strongSelf.configureCreateProgramViewController(createVC, navigationController: navigationController)
navigationController.pushViewController(createVC, animated: true)
}
}
Common app architecture with FlowControllers looks like this:
- Each Application has at least 1 FlowController, the root one created by your AppDelegate.
We actually have ApplicationController that creates it, which is owned by AppDelegate, as a rule of thumb you should never reference your AppDelegate, ever.
- Each FlowController can have child controllers.
If you app has some significant subset of user stories that can be thought as a whole and would require multiple screens (e.g. Creating new workout program) then you would create a new child FlowController for that part, and present it from your main flow controller.
- VC/VM don’t know about other VC/VM.
Which means they can be re-used anywhere, if one step would be importing something from user photos framework, you can reuse that code in different part of app, e.g., EditProfile can use same picker to select user avatar.
- Flow Controller configures and coordinates different screens.
- Each VC/VM defines interface to listen to their actions.
- If you need to support multiple devices and different presentation modes, you have other FlowController class, no spaghetti code.
The idea was originally introduced to me by Jim and Sami over a year ago, we have been using it constantly.
Even though our app changed drastically 3 times, our architecture took it with ease and we were able to reuse a lot of code, and quite a few controllers didn’t require any changes.
In my book the advantage of using an architecture like this are clear:
- Lack of dependencies between screens.
- High reuse rate.
- Simpler code injection and removal of any singletons.
- Cleaner code, the only spaghetti I see is the one I cook.
- More expressive way to guide the navigation.
- Ability to write different flow between devices while sharing most code.
- Testing each VC/VM in separation and with ease, since everything can be injected. No subclassing necessary.
Now in some architectures there are similar concepts e.g. VIPER has router. But they are usually pretty complex and require a lot of up-front cost to adapt into existing app.
What’s great about this approach is the fact it’s straightforward to adapt to it right now, no need to wait for new project to try it. And it works in small and big projects just as well.
Doesn’t matter if you use MVVM, MVC or other pattern, if you are pushing your screens from one to the other, give it a try.