Routing for iOS: universal navigation without rewriting the app

Azat Zulkarnyaev
Bumble Tech
Published in
10 min readDec 20, 2019

--

What’s wrong with navigation anyway?

Almost every app has a navigation between different internal components and it seems like it shouldn’t be a problem. UIKit contains more or less convenient containers like UINavigationController, UITabBarController and a flexible way to present screens modally. We, as developers, just need to use the right tools.

However, as soon as we start implementing a navigation to a screen that’s initiated by a push-notification or a URL, things start getting complicated. Straight away we need to start asking questions like:

  • What shall we do with current view controller?
  • How can we switch to the different part of the app (for example, switch a tab of UITabBarController)?
  • Do we have the needed screen in the navigation stack?
  • When should we ignore the navigation?

We came across such issues when developing iOS applications at Bumble — the parent company operating Badoo and Bumble apps — and decided to make a set of tools to solve them for all our apps. In this article, I’ll share the ideas behind these tools and describe our approach to implement them. For a practical example, check the small demo app.

Our problems

Problems with automatic navigation are often solved by introducing a global component which knows the structure of screens in the app and decides what to do in different situations. The structure of screens is understood as the information about which containers the app has and where and how some screens should be presented.

Badoo, our first product, had such a component which worked in a similar way to the old Facebook navigation library. It was based on the idea of an association of each screen with a URL. It has now been removed from their public repository and there is a reason for that.

  • Most of the logic was contained in a single class and this class was aware of the presence and the state of many components specific for Badoo, such as Tabs Bar.
  • Complexity and coupling of this component were often the cause of problems when implementing tasks requiring changes in the navigation. Testability of this class was also questionable.
  • Every team had to come up with a new solution because we couldn’t reuse Badoo navigation in new applications. This code was written when we only had one app. Back then we couldn’t predict that we would going to have several different products. And new solutions were also specified for each particular app.

As the number of products grew the problem became obvious. Consequently, we decided to create a framework which would include some universal components for performing navigation. This framework would both allow us to reduce complexity of the existing code as well as simplify the development of new apps.

Implementing the universal router

If you really think about it, there are not too many challenges for the global navigation:

  1. Find the active screen
  2. Somehow compare its content with the info that we need to show
  3. Correctly perform a navigation (or navigation sequence).

This list might look rather abstract but this abstraction allows us to build something universal.

Finding the active screen

This problem doesn’t look too difficult. We just need to traverse the hierarchy of presented screens and find the top UIViewController.

The finder protocol can look like this:

protocol TopViewControllerProvider {
var topViewController: UIViewController? { get }
}

But here we have two problems:

  • How do we get the root screen?
  • What do we do with containers like UIPageViewController or even some custom containers?

We can solve the first problem by taking a root view controller of the active window:

UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController

This approach won’t work with apps that have several windows but in such cases, we can add a window as a parameter.

The second problem can be solved by introducing a new protocol for retrieving an active screen of the container. But, as an easier alternative, we can use the same protocol that we declared above, TopViewControllerProvider. All controllers-containers used in the application should implement this protocol. For example, in the case of UITabBarController, the implementation will look like this:

extension UITabBarController: TopViewControllerProvider {
var topViewController: UIViewController? {
return self.selectedViewController
}
}

Now we need just traverse the hierarchy of screens and if some view controller implements TopViewControllerProvider protocol, we retrieve its child via declared method. Otherwise we consider controller presented on it as active (if there is one).

Current Context

The problem of fetching the current context looks more complicated. We want to detect the type of the active screen and probably what information is presented on it.

Creating a struct that contains the screen type and the content of the screen looks like a great start. But what types should these fields have? Our goal is to compare the context, that means they should at least implement Equatable. We can express this intent in generic types:

struct ViewControllerContext<ScreenType: Equatable, InfoType: Equatable>: Equatable {
let screenType: ScreenType
let info: InfoType?

However, Swift had restriction of usage of generic types and it can complicate the usage of this struct. To make things easier, in our apps, this structure has different implementation.

protocol ViewControllerContextInfo {
func isEqual(to info: ViewControllerContextInfo?) -> Bool
}
struct ViewControllerContext: Equatable {
public let screenType: String
public let info: ViewControllerContextInfo?
}

But both options are valid. In addition, the new feature of Swift, opaque type, can be used but it’s only available from iOS 13 which is still unacceptable for most apps.

The implementation of the equality function of the context is trivial but in order to avoid implementing isEqual functions for objects that implement ViewControllerContextInfo and already conform to Equatable we can use a simple trick:

extension ViewControllerContextInfo where Self: Equatable {
func isEqual(to info: ViewControllerContextInfo?) -> Bool {
guard let info = info as? Self else { return false }
return self == info
}

Now we have an object to store and compare the current context. But how do we associate it with UIViewController? One of the ways to do it’s to make an extension of the UIViewController class with a new associated object. But the problem is that firstly, it’s too implicit, and secondly, we usually only want to store a context for a limited number of screens, not for all of them. That’s why the best way to achieve our goal is to create a new protocol

protocol ViewControllerContextHolder {
var currentContext: ViewControllerContext? { get }

and implement it only where necessary. If the current screen does not implement this protocol its content can be considered as ‘not significant’ and therefore it can be ignored during the navigation to a new context.

Performing the navigation

Let’s see what we already have. We have a way to obtain the information about the active screen in the form of some kind of comparable data structure.

Information received via opened push-notification, a URL, or some other way of initiating the navigation can be transformed into a structure of the same type and be a sort of navigation intent. If active screen is already showing the same info, the intent can be ignored while is just causes the screen content to update.

But what about navigation itself?

It’s logical to create a class, let’s call it Router, which can take information about what we need to show as an input, compare it with data presented in the current screen and execute a navigation where needed. Also, router can contain a common logic of retrieving and validation of the information on the state of the app. The most important thing here is not to include the logic unique to any one particular application, such as knowledge about some feature or domain. Doing so ensures this class remain reusable and flexible.

The basic version of the protocol of this class can look like this:

protocol ViewControllerContextRouterProtocol {
func navigateToContext(_ context: ViewControllerContext, animated: Bool)

Passing a sequence of context will make it more general and it won’t change the implementation significantly.

The router takes only a context as an input, so we need a factory to create new screens. This factory will take the context and create new screens or even modules. The context type will help us decide what to create and the created screen will be filled with some initial data from the context’s info.

protocol ViewControllersByContextFactory {
func viewController(for context: ViewControllerContext) ->
UIViewController?

If our application is not to supersede the complexity of the Snapchat navigation, we will probably have very few ways to show a new screen. Most likely, updating the UINavigationController stack and the system modal presentation will be enough for most of the apps. Therefore we can define enum.

enum NavigationType {
case modal
case navigationStack
case rootScreen
}

The context needs to be considered when deciding what types of navigation to use. Some screens should be presented on top of everything else and others should just be added to the navigation stack. This logic can be specific for different apps. This is why it’s better not to add this code to the router but move it outside. Let’s introduce a new dependency under the protocol.

protocol ViewControllerNavigationTypeProvider {
func navigationType(for context: ViewControllerContext) -> NavigationType
}

But you’ve probably noticed that there is a potential flaw in this solution. If we will add a new type of navigation in an app, all other apps already using our enum will know about it and so we should probably change the internal logic of the router. In some cases, that’s maybe what we need but it’s not aligned with open-closed principle. To achieve greater flexibility we can introduce a protocol of the transition.

protocol ViewControllerContextTransition {
func navigate(from source: UIViewController?,
to destination: UIViewController,
animated: Bool)
}

ViewControllerNavigationTypeProvider will transform to

protocol ViewControllerContextTransitionProvider {
func transition(for context: ViewControllerContext) -> ViewControllerContextTransition
}

Now we are no longer limited by the fixed list of transition types and every application can have its own set of possible transitions.

Sometimes, to present some information, we don’t need to create a new screen but switch to an existing one. The simplest example is switching the tab of the UITabBarController. Or maybe, instead of creating a new screen we should pop to the existing screen in the current navigation stack. In order to do this, before creating a new screen and performing navigation, we need to check whether we can get to the same screen with the same info by simply switching the current screen. How can this be implemented? More abstractions are required, of course!

protocol ViewControllerContextSwitcher {
func canSwitch(to context: ViewControllerContext) -> Bool
func switchContext(to context: ViewControllerContext, animated: Bool)
}

Where tabs are in use, this protocol should be implemented by the component that knows about the content of the UITabBarController; can match passed view controller context with particular tab; and can switch tabs.

To sum up, the algorithm of the handling the context will look like this:

func navigateToContext(_ context: ViewControllerContext, animated: Bool) {
let topViewController = self.topViewControllerProvider.topViewController
if let contextHolder = topViewController as? ViewControllerContextHolder, contextHolder.currentContext == context {
return
}
if let switcher = self.contextSwitchers.first(where: { $0.canSwitch(to: context) }) {
switcher.switchContext(to: context, animated: animated)
return
}
guard let viewController = self.viewControllersFactory.viewController(for: context) else { return }
let navigation = self.transitionProvider.navigation(for: context)
navigation.navigate(from: self.topViewControllerProvider.topViewController,
to: viewController,
animated: true)
}

And here is the scheme of the router dependencies:

The global router can be used for the navigation initiated both automatically and also by user actions. But in our products, we use it mostly for automatic navigation caused by push notification or some server event. In other cases, we use default system methods and most of our modules don’t know about the existing router. In this case, it’s important not to forget to implement ViewControllerContextHolder where it’s needed.

Pros and cons

Let’s look at the benefits of the described approach and possible downsides.

Pros:

  • It’s universal, which means it can be easily integrated into existing and new apps.
  • The implementation of described protocols is relatively straightforward and conforms to SOLID principles.
  • There are no restrictions on how navigation is performed, and you can still use regular system calls and any high-level architecture
  • Testability: it’s much easier to test a set of simple small objects than one “god object”.

Cons — in some ways are consequences of the pros:

  • UIViewControllers should know about their context. In architectures like MVVM, MVP, VIPER or similar, view controllers should be considered as members of the View layer and therefore they should not contain any business logic. Although the context data can be injected into the view controller from the business logic level, the fact that view component stores some domain data is undesirable.
  • The source of truth about the state of the app is a hierarchy of screens. It can be insufficiently flexible for some cases.

Alternatives

There are a lot of other ways of implementing universal routing. One alternative, instead of using a hierarchy of view controllers, is to build an application screens hierarchy using a custom, tree-like structure. One of the implementations of the Coordinator pattern is a good example of this approach. Here, Coordinators store the information about the screen and decide how to react to the intent of routing to some screen. Also, they have children and this allows developers to build recursive structures and algorithms to handle the navigation.

RIBs architecture, used by our Android team, also has similar possibilities.

Such approaches deliver good flexibility and easy modularisation but require consistent architecture across all applications and can be over-complicated for small projects.

If you’ve used any other good approaches, please don’t hesitate to share them in the comments section below.

--

--