Photo by Joseph Barrientos on Unsplash

The Navigator

Another twist to iOS navigations

Rubén Méndez
Job&Talent Engineering
10 min readMar 9, 2020

--

App navigations, why care about them

Many iOS applications are pretty simple regarding navigation flows. In fact, having simple flows really helps to have a very easy-to-use app.

Many apps are based on a tab bar controller with some sections. Each section tends to have a navigation controller that stacks other view controllers. And very often, a view controller A always presents a view controller B. To handle that navigation, we traditionally end up adding this kind of code in our view controllers:

Traditional iOS navigation

It could be discussed if having this kind of code in the view controller is right or not, but in my opinion, this could work perfectly fine if your app is simple and it’s not going to grow in complexity.

But in more complex apps, like the Jobandtalent app, this kind of navigation doesn’t fit well and has several associated problems.

The Jobandtalent app has experienced many changes since it was originally launched. In a start-up, the business model is likely to change very often until the company finds its product-market fit, and our app wasn’t an exception. The Jobandtalent iOS engineering team had to face this fast-changing-context problem some time ago. Changing the business model had a direct effect on the navigation flows of the app, having to change very often the navigation stack. If an app doesn’t have a navigation architecture able to change easily, this becomes a problem when the team needs to iterate quickly. So we decided to really think about the navigation architecture of the app to make changes in a smoother and faster way. These were the main requirements we wanted to fulfill:

  • We should be able to change the navigation stack easily.
  • Reuse controllers in different navigation flows.
  • Reuse navigation flows.

Having navigation logic in the controllers was getting in the way of reusing them. Reusing controllers in different flows, and creating new flows was impossible if the controllers themselves were responsible for the navigation logic. If controller A always navigates to controller B, it would be difficult to make, in other scenarios, that controller A navigates to controller C without making the controller’s logic even more complex.

To fulfill these requirements, the iOS team has iterated over different navigation solutions. This article illustrates the different solutions we have applied, the trade-offs we have found and how we have reached the best possible solution for our application so far.

Routers

At the time we started to think about our navigation problem, VIPER architecture started to sound loudly in the iOS community. That architecture was based on the Clean Architecture concept of splitting the app into different responsibility layers. One of them, called Router, was in charge of handling the navigation logic. Routers are also called Coordinators in other architectures, but they are pretty much the same.

Using routers apparently fulfilled our initial requirements:

  • We could change the navigation stack easily. We could decide if we present something modally or pushed into a navigation controller without changing any code in a view controller.
  • We removed the navigation responsibility from the view controllers, so they could be reused in other flows.
  • Having packed a navigation flow inside a router, we could reuse easily navigation flows.

But after some time using this abstraction, we found that having the view controller layer separated from the router layer forced us to have code to sync them. Handling the lifecycle of routers became tricky. Routers should live as long as their underlying view controllers live, so dependencies get deallocated automatically when the navigation finishes. Routers make this quite difficult, with a lot of boilerplate and prone-to-error code to pull this off.

Trying to fit routers with UIKit made us feel uncomfortable, so we started to search for a lesser constrained approach.

Leverage Container View Controllers as Coordinators

After some research, we found a really interesting article where it was proposed to use something called “Manager View Controller” to coordinate navigations. We didn’t like the word Manager too much so we decided to give it a more semantic name to this abstraction. We called it FlowController.

A FlowController has the same responsibility of coordinating the navigation like a Router, but the main difference is that the FlowController is a subclass of UIViewController. The use of UIViewControllers as coordinators comes with a lot of useful features for free. The most relevant ones are:

  • View controllers can add other view controllers into their view hierarchy using the parent-child container relationship.
  • UIViewController has useful capabilities by default like being able to present modally or push into a navigation controller other view controllers (therefore able to present other FlowControllers) or even use some useful patterns like the responder chain.

The main idea of using FlowController is to act both as a container and a coordinator. It can contain the controllers to be presented in a piece of navigation, and those contained controllers can delegate their navigation needs to the FlowController. Since FlowControllers are UIViewController subclasses, they can be integrated easily with the navigation stack of the application, thus, this reduces the two-layer-sync complexity we had with Routers. When the FlowControllers get out of the navigation stack, they are released, as well as all their child view controllers and all their dependencies, automatically.

For a more detailed comparison between FlowControllers and Routers, please, read this nice post about the topic.

Dead by FlowControllers

After these iterations, we were pretty confident about what we had achieved in terms of improving navigation. We ended with all of our application flows embedded in different coordinators. Everything seemed pretty fine, our view controllers were not aware of navigation code anymore, we could change the navigations easily, it was very easy to create and present other coordinators using the FlowController abstraction, so everything was happiness.

But after some time using flow controllers we faced up with the following problem: reusing them was not as simple as we thought.

The problem was having to reuse a subset of the navigation embedded in a flow controller. If we needed to reuse that subset we were forced to split a flow controller into smaller ones to reuse the subset. That doesn’t seem to be a big issue, but each time we had to reuse a piece of navigation subset the development speed was affected and also the number of flow controllers raised more than we expected.

This problem made us think that maybe we were adding an unnecessary layer of complexity to the project.

Killing FlowControllers

We realized that having so many flow controllers was making our navigation logic very complex. In fact, some questions were flying around our heads. Do we really need flow controllers? Can we simplify our navigation logic so we can remove that abstraction from our architecture?

Let’s review one of our main requirements, decoupling view controllers from the navigation logic.

The main reason to decouple our view controllers from navigation was to be able to change the navigation easily or to have the same view controllers navigating to different flows depending on the business logic. But we realized that it didn’t happen very often. The view controller A always was presenting controller B and there were very few corner cases where controller A had to present a different controller. Thus, we were using an abstraction to only support very few corner cases, if any. This is a perfect example of early abstraction. We were making things more complicated without really needing it.

What we actually needed was an easy way of presenting view controllers, abstracting the view controllers from the navigation stack. A collaborator to whom messages like “present this view controller with this navigation traits”, can be sent. This is what we have called The Navigator.

The Navigator is a component that can perform a previously modeled set of navigations. This model defines which view controller to present and how we want to present it. The view controllers are only in charge of commanding that navigation to the Navigator, but they are not aware of how to instantiate the view controller to present, build any required dependency or knowing the navigation stack where the new view controllers will be pushed onto.

Modeling navigations

Before going into detail about what the Navigator is for us and how it is implemented, we need a way to tell the navigator the controller to navigate to and how to perform that navigation. If we analyze carefully what the main navigations on an iOS app are we could find the following types:

  • Present a section of the UITabbarController.
  • Push a UIViewController on top of a UINavigationController.
  • Present modally a UIViewController.

Taking that analysis into account we can model our possible navigations into a Navigation model using value semantics. This is an example of our Navigation model:

Navigation model

This simple Navigation model is defining all possible navigations of our application. The section case is describing a UITabbarController navigation with all sections defined in the Section model. Also, this Navigation model includes the possibility of presenting a UIViewController, modeled with the Screen struct, either modally or pushed into a UINavigationController.

Modeling navigations with enums and default associated values has allowed us to pass optional parameters to configure navigations in cases where more customization is needed. Most of the times using .modal(.screen) is more than enough, but it would be possible to be more specific about the presentation style, for example using .modal(.screen, .popover).

Our first implementation of the Screen model was a simple enum, but we realized that scaling an enum adding cases was easy but the Screen enum became a massive file. The idea of using a struct and wrapping the view controller into a property allows us to extend easily the Screen struct adding static functions that return Screen instances. Those extensions can be in their own files, or even in their own modules so we avoid having a massive file with all possible screens. Another consideration is that the view controller property is not actually a UIViewController property, instead, it is a function. This is to load the view controller lazily just when it is needed, not when the navigation is created. Find some examples below:

Screen extensions

The Navigator

It is time to introduce the missing part of our navigation architecture. The Navigator is the logic piece in charge of performing the navigation defined by our Navigation model.

Simple Navigator implementation

The implementation of the navigator is pretty simple. We have some utility functions which the navigator uses to simplify the navigations:

  • tabBarController() returns the main tab bar controller of the application. The navigator will tell it to present a concrete index associated with a section.
  • topMostViewController() returns the currently presented view controller on the screen. This way, the navigator can present any view controller modally over this controller.
  • currentNavigationController() returns the current navigation controller presented on the screen. The navigator just needs to push a new view controller into the current navigation controller.

The key thing about those methods is that they are computed and aware of the view hierarchy in order to dynamically retrieve those. That means that the current view context is automatically figured. Navigation-wise, we usually always push on top of the current navigation stack. When we previously had different routers and coordinators maintaining and wrapping each of the current navigation stacks within each of the tabs, we now simply have a way to ask “hey, which is the current navigation controller visible?” and let that be retrieved at runtime, regardless of the tab we are at.

Inside our architecture, the navigator is a single instance which is told to perform a piece of navigation, for example:

Example of how to use the Navigator

As we can see, we have reduced a lot the complexity to handle navigations. We have a straightforward model defining navigations leveraging value semantics, a very simple piece of logic to perform them, and very important for us, an effortless way of commanding navigations from the effect handler of our architecture. The effect handler is totally abstracted from the navigation hierarchy of the app. It only knows which navigation to perform but not the way it is performed.

This way of abstracting navigations is more consistent with the current Jobandtalent application architecture, nicely explained by Luis Recuenco in the following post series about state container architectures. Our Navigation model is the value describing the navigation and the Navigator is the interpreter of those values, converting those navigation values into navigation effects (view controller presentations).

Besides, having the navigation modeled as value types has the advantage of leveraging snapshot-testing to test navigations in an easy way. For example, this is a real test in Jobandtalent application:

Snapshot test example

This test creates a NavigationSpy to replace our real Navigator implementation. It then performs an action on the view controller which should eventually trigger a piece of navigation on the Navigator (NavigatorSpy). Finally, the result of the snapshot navigation test is:

Resulting snapshot reference file

If any change or refactor breaks this navigation, the snapshot test will warn us about that fact. We have replaced tests that would require quite a difficult mocking infrastructure to intercept calls and parameters in a simple, broad-coverage snapshot test that prevents us from nasty refactoring mistakes.

And that’s it! We have completely removed the need to have many coordinators in favor of having just a simple component to handle that responsibility. We have leveraged the possibility of showing any view controller from any part of the application, reusing the navigation logic with a very simple implementation.

Conclusion

We don’t know if the Navigator will be our last iteration about navigations because, at Jobandtalent, we like questioning our own implementations and architectural decisions constantly, but we are very happy with the result obtained so far.

I have had many conversations about navigations with other colleagues and I have seen that the Coordinator pattern is the most popular approach in their projects. The Coordinator pattern is a great way of handling the navigation in an iOS app, but you might also not need that extra complexity. Do not adopt an architecture or pattern because of fashion trends, without questioning their trade-off. Do not over complicate our applications without questioning things, and remember that we have created great apps using a very simple approach.

Take into account that this navigation architecture fits our project needs, but other projects or applications may need a different way. The point of this article is to provide some inspiration for other projects and to show our refinement process to get something more adapted to our application needs. One of the main goals of the iOS team at Jobandtalent is to seek happiness in our day-to-day development and being critical with our own decisions. This navigation analysis is just one example of that.

Thanks to Luis Recuenco and Victor Baro for all the valuable feedback while developing The Navigator.

We are hiring!

If you want to know more about what it is like to work at Jobandtalent, you can read the first impressions of some of our teammates on this blog post or visit our Twitter.

--

--