Screen navigation in iOS

Bohdan Orlov
Bumble Tech
Published in
11 min readDec 19, 2017

In this article, we will go through different ways of presenting screens in the iOS app. We will start with most straightforward cases and eventually go through some advanced scenarios. In a nutshell we will be adding more abstraction layers to make our screens decoupled and navigation testable by unit tests. Some code examples are available here. Please note that the ways of doing navigation that we described here are not necessarily compatible, and it might be a bad idea to use all of them at the same time.

A UIViewController to UIViewController presentation

This is the most basic way of screen presentation which is encouraged by Apple:

We can do this by doing one of the following:

1) Manually allocating a UIViewController for the next screen and presenting it.

private func presentViewControllerManually() {
let viewController = DetailsViewController(detailsText: "My
details") { [weak self] in
self?.dismiss(animated: true)
}
self.present(viewController, animated: true)
}

2) Allocating of a UIViewController from a xib file or a storyboard.

private func presentViewControllerFromXib() {
let viewController = DetailsViewControllerFromIB(nibName:
"DetailsViewControllerFromXib", bundle: nil)
viewController.detailsText = "My details"
viewController.didFinish = { [weak self] in
self?.dismiss(animated: true)
}
self.present(viewController, animated: true)
}
private func presentViewControllerFromStoryboard() {
let viewController = UIStoryboard(name:
"DetailsViewControllerFromIB", bundle: nil)
.instantiateInitialViewController() as!
DetailsViewControllerFromIB
viewController.detailsText = "My details"
viewController.didFinish = { [weak self] in
self?.dismiss(animated: true)
}
self.present(viewController, animated: true)
}

3) Performing segues from Storyboards.

self.performSegue(withIdentifier: "detailsSegue", sender: nil)
...
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == "detailsSegue" else { return }
let viewController = segue.destination as!
DetailsViewControllerFromIB
viewController.detailsText = "My details"
viewController.didFinish = { [weak viewController] in
viewController?.performSegue(withIdentifier: "unwind",
sender: nil)
}
}

The second and the third approach force us to use two unpleasant practices:

1) using string identifiers, which quickly results in a crush in the runtime if you change them in IB or mistype them in the code.

We can work around this drawback by using third-party tools for compile-time safe identifiers.

2) two-step initialization of the UIViewController: this means that properties of the UIViewController have to be set to ‘var’ even though the existence of the UIViewController doesn’t make sense without those properties set.

The first approach, manual allocation, allows us to initialize the UIViewController with needed dependencies and make those properties constant with the ‘let’ storage type.

This approach requires us not to use the interface builder and create all UI elements in code, which takes more time and results in more code to support, but if results in the most robust application.

Each of approaches mentioned above remains a valid way of doing navigation, but still, they all have two negative consequences:

1) it is impossible to write unit tests for such presentations. Of course, we can always use OCMock and swizzle initializers for the presented UIViewController, but this approach will not work for pure Swift classes, and so it is a bad practice to adopt.

2) screens are coupled: the presenting UIViewController explicitly creates the presented UIViewController, so it knows what screen it is. If at some point you want to present another view controller you will be tempted to put an ‘if’ statement to make this decision thereby putting, even more responsibilities into the presenting UIViewController.

3) the presenting UIViewController knows what happens when the button is pressed — the new screen is presented. Thus, there is no graceful way to change what happens when you press the button if screen A is reused elsewhere.

Testable and decoupled navigation

Dependency Injection and protocol-oriented programming come to our rescue and allow us to address the issues mentioned above.

Dependency injections will allow us to decouple the presenting and presented controller, and so let’s inject a details view controller using a factory closure:

let detailsViewControllerProvider = { detailsText, didFinish in
return DetailsViewControllerToInject(detailsText:
detailsText, didFinish: didFinish)
}
examplesViewController.nextViewControllerProvider = detailsViewControllerProvider

Now we can call a closure to get a UIViewController to present:

private func presentInjectedViewController() {
let viewController: UIViewController =
self.nextViewControllerProvider("My details") { [weak self] in
self?.dismiss(animated: true)
}
self.present(viewController, animated: true)
}

So now the only thing we know is that to show next screen, we have to pass details text and completion closure, and this will produce some UIViewController to present.

Using protocols to cover our implementation details allows us to separate decisions from actions (implementation) and test the code. Let’s cover presentation functions by the protocol:

protocol ViewControllerPresenting {
func present(_ viewControllerToPresent: UIViewController,
animated flag: Bool, completion: (() -> Swift.Void)?)
func dismiss(animated flag: Bool, completion: (() ->
Swift.Void)?)
}

Conform UIViewController to this protocol:

extension UIViewController: ViewControllerPresenting { }

Inject the UIViewController disguised as a presenter like this:

let presenterProvider = { [unowned examplesViewController] in 
return examplesViewController
}
examplesViewController.presenterProvider = presenterProvider
// presenterProvider will return examplesViewController itself

And write a test using a mock implementation:

let mockPresenter = MockViewControllerPresenter()
examplesViewController.presenterProvider = {
return mockPresenter
}
// When select cell causing presentation
examplesViewController.tableView(examplesViewController.tableView,
didSelectRowAt: IndexPath(row: 1, section: 1))
// Then presented view controller is the injected VC
let vc = mockPresenter.invokedPresentArguments.0
XCTAssertTrue(vc is DetailsViewControllerToInject)

If you think about it, we didn’t change the way presentation works; the presenter is still the same UIViewController. The beauty of this solution lies in the UIViewController (former ‘self’) performing the presentation under the hood of a custom presenter. This allows you to inject a custom presenter if you want to change the way the view controller is presented or in order to test this code.

Domain specific navigation

One question remains unanswered, how can we use ‘self.navigationViewController’ and test push presentations? In fact, Apple encourages hiding the details of presentation, and this is why it is recommended to use -showViewController and -showDetailsViewController. So, I suggest you should either extract the methods above to protocol in the same way we did for ‘presentViewController’ or introduce a small domain specific navigation API in your app. Let’s try to implement the second approach.

Declare the type of presentation you want to support in a protocol:

protocol ViewControllerPresentingWithNavBar:   
ViewControllerPresenting {
func presentWithNavigationBar(_ controller: UIViewController,
animated: Bool, completion: (() -> Void)?)
func dismissRespectingNavigationBar(animated: Bool,
completion: (() -> Void)?)
}

Conform the UIViewController to the protocol by creating a navigation controller if necessary:

public func presentWithNavigationBar(_ controller: UIViewController, animated: Bool, completion: (() -> Void)?) {
if let navigationController = navigationController {
navigationController.pushViewController(controller,
animated: animated, completion: completion)
} else {
let navigationController =
UINavigationController(rootViewController: controller)
self.present(navigationController, animated: animated,
completion: completion)
let button = UIBarButtonItem(barButtonSystemItem: .cancel,
target: self, action: #selector(userInitiatedDismiss))
controller.navigationItem.leftBarButtonItem = button
}
}

Inject a UIViewController as a presenter:

let presenterWithNavBarProvider = { [unowned examplesViewController] in
return examplesViewController
}
examplesViewController.presenterWithNavBarProvider =
presenterWithNavBarProvider

And usage is straightforward:

private func presentDetailsWithNavigationBar() {
let presenter = self.presenterWithNavBarProvider()
let viewController = self.nextViewControllerProvider(
"My details", didFinish: { [weak self, weak presenter] in
presenter?.dismissRespectingNavigationBar(animated: true,
completion: nil)
})
presenter.presentWithNavigationBar(viewController,
animated: true, completion: nil)
}

Extracting decisions into ActionHandlers

Now, let’s fix the last issue: we want to hide the details of what is happening after the user taps the button by introducing another protocol:

protocol ActionHandling {
func handleAction(for detailText: String)
}

We create an ActionHandler:

class ActionHandler: ActionHandling {
private let presenterProvider: () -> ViewControllerPresenting
private let detailsControllerProvider: (detailLabel: String,
@escaping () -> Void) -> UIViewController
init(presenterProvider: @escaping () -> UIViewController,
detailsControllerProvider: @escaping (String, @escaping ()
-> Void) -> UIViewController) {
self.presenterProvider = presenterProvider
self.detailsControllerProvider = detailsControllerProvider
}

And move the presentation code there:

func handleAction(for detailText: String) {
let viewController = detailsControllerProvider(detailText) {
[weak self] in
self?.presenterProvider().dismiss(animated: true,
completion: nil)
}
presenterProvider().present(viewController, animated: true,
completion: nil)
}
}

So, in the view controller we only have this:

private func handleAction() {
self.actionHandler.handleAction(for: "My details")
}

In a real app, you might want your ViewModel to be the action handler. If you do this, it means that the ViewModel will know about a UIViewController. This is arguably bad, since on the one hand it violates The Dependency Rule from the Clean Architecture, and on the other hand, we can think about the ViewModel as a Mediator between UI and Use Cases (business logic), so it must now be about involved parties.

Provided we don’t want to expose UIViewController to a ViewModel we can create a ScreenPresenting protocol:

protocol ScreenPresenting {
func presentScreen(for detailText: String,
didFinish: @escaping () -> Void)
func dismissScreen()
}

And use it in the following way from the ViewModel:

class MyViewModel: ActionHandling {
let screenPresenter: ScreenPresenting
init(screenPresenter: ScreenPresenting) {
self.screenPresenter = screenPresenter
}
func handleAction(for detailText: String) {
screenPresenter.presentScreen(for: detailText, didFinish: {
[weak self] in
self?.screenPresenter.dismissScreen()
})
}
}

Essentially there is not much difference between ScreenPresenting and ActionHandler, but we just added another layer of abstraction to avoid injecting UIViewControllers into the ViewModel.

Module to module navigation

A possible alternative approach is to build the app using collaboration between Flow Coordinators. A great introduction into flow coordinators is available here.

A Flow Coordinator is a facade and entry point to a Module. A Module is a group of objects which might consist of a UIViewController, ViewModel/Presenter, Interactor and Services. A Module does not necessarily have a UI.

Typically, the root Flow Coordinator is retained and started by the App Delegate:

func application(_ application: UIApplication, 
didFinishLaunchingWithOptions launchOptions:
[UIApplicationLaunchOptionsKey: Any]?) -> Bool {
self.window = UIWindow(frame: UIScreen.main.bounds)
self.window?.rootViewController = UIViewController()
self.window?.makeKeyAndVisible()
self.appCoordinator = AppCoordinator(rootViewController:
self.window?.rootViewController!)
self.appCoordinator.start()
return true
}

A Flow Coordinator can own and start child Flow Coordinators:

func start() {  
if self.isLoggedIn {
self.startLandingCoordinator()
} else {
self.startLoginCoordinator()
}
}

A Flow coordinators allow us to organise collaboration between modules built with different levels of abstraction, for example Flow Coordinators for MVC and VIPER modules will have the same API. The important caveat about Flow Coordinators is that they will force you to maintain a hierarchy of Flow Coordinators parallel to the UI hierarchy. This might cause problems because UIViewControllers and ViewModel don’t keep strong references to Flow Coordinators, and you have to be very cautious to make sure that modules do not run into an inconsistent state when a UIViewController is dismissed, but the Flow Coordinator driving it still exists.

A two-part tutorial on testable Flow Coordinators is available here.

It is also possible to adopt Flow Coordinators gradually, and this means that your first Flow Coordinator is likely to be retained by your UIViewController or a ViewModel/Presenter instead of UIAppDelegate. In this way, you can introduce Flow Coordinators for new features, avoiding the need to refactor of the whole app.

Handling opening from a deep link or a push notification

These two problems eventually boil down to a requirement of having a centralised navigation system. By a centralised system, I mean an entity which is aware of the current stack of navigation and can operate on it as a whole thing.

From my observations, there are a couple of rules you have to respect when creating a centralised navigation system:

1) Presentations via the System should not break existing UIViewController based navigation

2) UIViewController based navigation should not be forbidden when the System is introduced.

The System can solve the following two problems:

1) presenting a screen (or a hierarchy of screens) relevant to a push notification or a deep link.

2) handling of priorities (opening pushes or deep links over blocking screens)

Opening a stack of screens.

A naive version of doing this task is as following:

1) pop to the root view controller

2) present some view controllers sequentially

final class Navigator: Navigating {
func handlePush() {
self.dismissAll { [weak self] in
self?.presentTwoDetailsControllers()
}
}
...
}

PresentTwoDetailsControllers might look something like this:

private func presentTwoDetailsControllers() {
let viewController = self.controllerForDetailsProvider(
"My details") { [weak self] in
self?.navigationController.dismissRespectingNavigationBar(
animated: true, completion: nil)
}
self.navigationController.presentWithNavigationBar(
viewController, animated: true, completion: { [weak self] in
guard let sSelf = self else { return }
let viewController2 = sSelf.controllerForDetailsProvider(
"My another details") { [weak viewController] in
viewController?.dismissRespectingNavigationBar(
animated: true, completion: nil)
}
viewController.presentWithNavigationBar(viewController2,
animated: true, completion: nil)
})
}

As you see, this type of approach is not scalable because it requires manual handling of each case. One way of making this scalable is to build a more complicated system based on graphs.

The implementation could be as follows:

1) build two trees of UIViewControllers, actual and required.

2) pop UIViewControllers until the actual hierarchy is a subset of the required hierarchy.

3) push the required view controllers until they match the required hierarchy.

This type of approach requires the ability to create and present screens independently of each other. So, if your screens are communicating not directly but via services, it is much easier to develop such a system.

There are multiple ways of mapping deep links and pushes to screen hierarchies. Check this article for example.

Handling modal blockers

Often your app might require interaction to be blocked until a user enters a PIN or confirms some information.

The system must handle those types of screens in a particular way which matches your product expectations.

The naive solution could be just to ignore any requested hierarchy changes if there is a blocking screen.

func handlePush() {
guard self.hasNoBlockingViewController() else { return }
self.dismissAll { [weak self] in
self?.presentTwoDetailsControllers()
}
}
private func hasNoBlockingViewController() -> Bool {
// return false if any VC in hierarchy is considered to be a
blocking VC
return true
}

The more advanced approach is to associate a priority with a screen and treat screens with different priorities differently. The exact solution will be dependent on your domain, but could be as simple as not presenting screens with lower priorities unless there are screens with higher priority in the hierarchy.

Alternatively, you might want to show modal screens based on their priority: showing a screen with top priority and keeping rest in a stack until the top one is dismissed.

Conclusion

In this article, I shared some thoughts on screen presentation on iOS and problems you might need to solve in your app. As you may have noticed the trickiest part is the handling of interruptions from pushes and deep links, all of which requires an in-depth consideration of all scenarios in your particular case, and that is why there is no a third party solution to these issues.

--

--

Bohdan Orlov
Bumble Tech

iOS head. ex @IGcom 📈, @MoonpigUK 🐽, @Badoo 💘 and @chappyapp 🖤 Lets' grow together 🌱@bohdan_orlov http://arch.guru