Nivelir: a convenient DSL for navigation

Scalable and composable routing for iOS apps

Almaz Ibragimov
hh.ru
17 min readApr 12, 2022

--

For over a year, we reviewed our implementation of routing in hh.ru iOS apps. At that time, it resembled a simple screen layout layer rather than routing. Reconciling this sad fact, we began to explore the navigation: we reviewed many approaches in iOS, tried each in the project sandbox and even made our way to Cicerone from the Android world.

So, we took the best of all the reviewed solutions and reworked them into our own implementation, which now fits perfectly with our navigation requirements. Recently we started to bring our developments into a separate open-source project — Nivelir. This article will help you understand it and show how routing is done in our projects.

What should the routing be like?

We wanted to separate the navigation description from its implementation, so that a once-declared transition could be reused for both the navigation implementation and another transition declaration. So we identified some basic requirements for our routing:

  • Composability: complex navigation should be built from simple, clear and reusable items.
  • Usability: it makes no sense to use routing with syntax that is more complex and voluminous than the standard means.
  • Scalability: routing should provide a sufficient level of abstraction to add new functionality.
  • Universality: we have many old screens on different heritage architectures (MVC, VIPER), and the routing needs to support navigation to any of such screens.
  • Strong typing: developers need to see their mistakes at compile stage, so that navigation is completely safe in runtime.

Sadly, it is impossible to describe the navigation separately from its implementation with standard tools. UIStoryboardSegue objects are not included, they are used only in Storyboards. The data transfer between the screens in this case can hardly be convenient and safe. Plus, there is no possibility to compose navigation objects in any form out of the box.

Our navigation requirements were implemented by creating a high-capacity DSL, which may be interesting for other projects as well. In any case, someone else’s experience is always useful, so the article contains a lot of details on the implementation of the basic structure. So, let’s start the review with it.

Basic Structure

Nivelir is based on the 6 basic items:

  • Screen container: any object capable of navigating. The simplest example — UIViewController.
  • Module builder: an abstraction to build a screen module, it may be a MVVM, VIPER or MVC
  • Navigator: input point to perform navigation actions.
  • Navigation actions: the simplest operations that are used by the navigation itself or by other actions.
  • Routes: navigation description in the form of an action set.
  • Decorators: wrappers over the module builder that modify the container being created.

In brief, the general scheme is as follows: the navigator receives an input with a set of navigation actions in the form of a route. These actions are done in certain containers and can create new ones with the help of the module builder. At the same time, the created containers can be specialized by its usage with the help of decorators.

In addition to these high-level items, there are also inner layers. We’ll discuss them in more detail once we view the base.

Screen container

Any navigation is carried out in a so-called “screen container”. For UIKit, they can be divided into several types:

  • Window container is an UIWindow instance. That is each screen has a window to be displayed in.
  • Tab container is a UITabBarController instance.
  • Stack container is a UINavigationController instance.
  • A modal container is any UIViewController instance that can be used to modally present another screen.

Since both UITabBarController and UINavigationController are subclasses of UIViewController, they act as modal containers, too.

In code, a container corresponds an empty protocol ScreenContainer:

Both UIWindow and UIViewController and, consequently, all its subclasses are conformed to this protocol. But to use these containers, you need to get them — this is the Module Builder’s responsibility.

Module builder

It is a good practice to allocate a separate item to build a screen module. External dependencies are introduced in it, module layers are created and linked together. The result of the building is a container for navigation. In the case of UIKit, it is most commonly a controller.

Nivelir determines protocol Screen for such builders:

Each builder is associated with the type of container it creates. In practice, it is usually a UIViewController, but modules with UINavigationController or UITabBarController as a container are also possible.

In the build(navigator:) method, it is necessary to implement the module building and return a container of the appropriate type. For the screen navigation, you should use a navigator instance, which is passed in the navigator parameter.

Each navigator has a name that has been assigned in the name parameter. The default implementation of the Screen protocol returns its type name for this property, and there is no need to define it independently.

The traits property defines a set of screen identifiers. For example, if the stack contains two chat screens with different interlocutors, their distinguishing traits will be the chat ID.

Why does the screen need a name and distinguishing traits?

The screen key is generated from the name and traits properties in the key property, which is defined in the Screen protocol extension:

This screen key can be used to find the container in the hierarchy if it implements the ScreenKeyedContainer protocol:

Conforming the container to this protocol and declaring a screenKey property which value can be passed from the module builder is enough.

What does the implementation look like?

For example, let’s take a simple chat screen that accepts only one external parameter — the chat ID. In this case, it’s controller will look as follows:

Controller conforms to the ScreenKeyedContainer protocol, hence it becomes possible to find it in the hierarchy. Besides the screen key, the initializer accepts the navigator instance that the controller will use for its own navigation.

The implementation of this screen module builder will look like this:

In this example, it would be more convenient if the builder corresponds to the Equatable protocol instead of manually defining the chat ID in the traits property. Then an instance of the module builder could be used to find the container. But in real projects there will be many other items in its fields that don’t correspond to the Equatable protocol. For example, services.

In this case, we would have to write a comparison implementation, and we didn’t want to be forced to allocate a separate structure for each screen with its data. So, we came up with a convenient compromise as a traits property.

What if there is no need to use a builder?

For the simplest screens, consisting of a container only, a separate builder may be redundant. In this case, you can assign the controller to the Screen protocol:

Then the default implementation of the Screen protocol will return itself in the build(navigator:) method, and a controller instance can be used in the navigation actions.

How to erase a builder type?

Since the module builder is associated with the container type, it cannot be used by the Screen type. To avoid passing the builder according to a specific type, you can use the AnyScreen structure.

For convenience, the default implementation of any builder includes the eraseToAnyScreen() method, which wraps an instance in AnyScreen. And there are aliases for AnyScreen suitable for its container type to make the constructs as compact as possible:

So the method that returns the chat module builder might look like this:

We have learned how to build screens, now it’s time to deal with the navigator structure.

Navigator

Navigator functions as a routing input point. It carries out the navigation actions, logs their bugs and messages, searches for containers in the window hierarchy. But it doesn’t do all this by itself, but delegates it to three components:

  • ScreenWindowProvider — provides a window for navigation.
  • ScreenIterator — iterates through the hierarchy to find the container.
  • ScreenLogger — logs navigation events.

Each of these items can be replaced by its own implementation, but in most cases, this is not necessary: the standard navigator initializer only needs to pass the window instance in the window parameter.

It is assumed that each UIWindow instance in the app will have its own navigator, so it is better to create it along with the window:

Navigation actions trigger the module builder build(navigator:) method with the navigator instance that performs them. The module must use the received navigator for its own routing — this is necessary since navigation often involves a container search, and it is important to ensure that this search is performed in the right window hierarchy.

The majority of navigator methods are more useful for navigation actions implementation. So, let’s start with tackling them and look at the usage example later.

Navigation actions

The most scalable layer in Nivelir is precisely the navigation actions, and we have developed a number of properties for them. Actions can:

  • Perform transitions, such as present, push, etc.
  • Do not change the screen hierarchy at all, e.g. search for a container.
  • Perform a set of other actions.
  • Merge with other actions.

All the navigation actions conform to ScreenAction protocol:

This protocol is associated with the container type to which the action is applied and with the return value type. The perform method accepts the container and returns the result of the execution, calling completion closure.

To allow actions to be combined, the protocol requirements include the combine method, which returns a new action with an erased type if the combination succeeds. But not all actions can be combined, so the default implementation of this method returns nil.

Why is it a good idea to combine actions?

Frequently, in apps, you have to rebuild an entire stack of screens. For example, when a user opens a deeplink. Let’s assume that we need to perform three actions for such a deeplink:

  • Find the stack in chat tab
  • Reset stack to a list of chats
  • Push the chat screen into the stack

If you perform these actions separately, in a situation where there is more than one screen in the stack, the user will see a double animation. First a return to the chat list, then a push of the new screen:

It’s much more fun to combine these actions into a single the setViewControllers method call, which will apply only one animation for this kind of navigation:

Therefore, Nivelir provides only one action to change the navigation stack — ScreenSetStackAction, which is initialized with an array of so-called stack modifiers — basic operations like pop, push, etc.

The execution of ScreenSetStackAction consists of calling all its modifiers one by one to get the final stack, which is then sent to the setViewControllers method. And sticking two such actions together is a simple modifier summary.

What do the stack modifiers look like?

The stack modifiers correspond ScreenStackModifier protocol:

The perform(stack:navigator:) method accepts the current stack in the stack parameter and returns it in a modified form. Using the ScreenStackPushModifier as an example, its implementation looks like this:

There are 4 implementations of modifiers available out of the box, they are enough for any navigation with stack:

  • ScreenStackPushModifier: adds a new top screen to the stack.
  • ScreenStackClearModifier: removes all the screens from the stack .
  • ScreenStackReplaceModifier: replaces the stack top screen.
  • ScreenStackPopModifier: removes the top screens from the stack.

The ScreenStackPopModifier modifier has its own predicate, which allows you to remove the top screens from the stack under a certain condition. You can implement your own conditions or use standard predicates: previous, root, etc.

What other actions are there?

All the navigation actions can be divided according to the container type, in which they are performed:

  • Window actions: applied to the UIWindow container.
  • Modal actions: applied to the UIViewController container.
  • Tab actions: applied to the UITabBarController container.
  • Stack actions: applied to the UINavigationController container.
  • General actions: applied to any container.

Keep in mind that modal actions can also be performed with UITabBarController and UINavigationController as they are subclasses of UIViewController.

Apart from the basic actions, there is also an Addons layer with comfortable extensions: alerts, photo selection, opening URLs, etc. And if this is not enough, you can always add your own specific actions.

Routes

Any navigation consists of at least 2 actions:

  • Obtaining a container: known in advance or found in the hierarchy.
  • Executing a transition in this container: present, push, etc.

That’s why the navigation actions are not applied independently but are a part of a separate item for navigation description — routes. They are presented as ScreenRoute structure, that implements the ScreenThenable protocol:

The basic route can be modified by adding actions and other routes via corresponding actions then(:). All of them are limited by the Current type that is a type of the current container.

The set of all actions of a route is available in the actions property, and it is linked to the root container type, where the navigation starts and is described by this route. This root container corresponds to the associated Root type.

What’s a better way to add actions in route?

In fact, navigation actions don’t need to be added to the route directly, it is better to use special methods from the ScreenThenable protocol extensions. These methods are implemented for each action and based on the example of closing a modal screen they look as follows:

The dismiss(animated:) method creates an action instance and adds it to the route by triggering the then(:) protocol method. This is how basic extensions are implemented, and more complex ones can use methods of other actions or even combine them.

These extensions are useful for easy route description. You don’t need to remember the action type. Just enter a dot in the declaration, and Xcode will suggest a list of all available methods which are applicable to the current container type. In addition, this greatly reduces the code of the declarations themselves and increases their readability.

How to declare a route?

Navigation description always starts with a root route whose containers type Root и Current are equal. Nivelir provides ready-made aliases for such routes according to their container type:

The most common of these aliases is ScreenWindowRoute. Its instance starts the description of the main route. For example:

This route includes 2 actions: search for a top stack and push chat screen. The first action will receive the window instance as a container, and the second will use whatever is found.

The other aliases are used mainly for composition, for example, the same root can be divided into two:

This example describes the same navigation, but with a reuse of the pushRoute, which starts with the ScreenStackRoute instance, since you can push the screen only into the stack.

What type of a route would it be?

Some actions may change the Current container type of the route, with only the Root container remaining unchanged. For example, invoking top(.stack) will make the UINavigationController to be the current container, and subsequent actions will be done on it, until some action changes the container again.

This allows you to chain an reused route with the current container, but can be troublesome when defining its type in methods. For example:

Such an entry cannot be called compact, and in addition, the type can be changed after modifying the route itself.

iOS projects starting with version 13 and higher are lucky, Opaque Types are available for them. You can use the ScreenThenable protocol with the keyword some and forget about a specific type:

A less convenient but universal solution is to make the route be root by adding a invoke to the resolve() method at the end:

In this case, the route will have the same type it was created with, but the compiler will no longer allow it to push the second screen in the stack after invoking resolve(), because the current container will be a UIWindow.

How to perform a route?

To make a navigation, it is enough to feed the navigator with route:

The navigate(to:) method accepts only routes that have a Root container equal to UIWindow, i.e. ScreenWindowRoute. The navigator will perform the route actions sequentially, passing its window instance to their input.

If in our example the top stack is not found in the hierarchy, the navigator will log the bug, and you can define a completion in the completion parameter to process the result by yourself. But if other navigation actions are required in case of a bug, such logic can easily be described in the route.

How to process errors in the routes?

Let’s suppose we want to fallback to a modal chat presenting in case the top container is not a stack. Then we can use the fallback(to:) method and pass a different route to it:

The presentRoute route searches for the top container in the hierarchy regardless of its type and modally presents the screen on it. The fallback(to:) method will trigger it if pushRoute does not find the top stack.

What if we know the container so far?

For local navigation, we don’t need to search for a container, we already know it — it’s our controller. In this case, we can use the from(:) method. Let’s suppose we have a property container, in which a normal controller is declared, then an example of its use looks as follows:

Since our container is not a stack, first we need to get it with the stack property. The compiler will not allow us to push the chat screen directly in the modal container.

If you prefer, you can combine the description of a route with its implementation. For local navigation cases, this may be more convenient if there is no need to reuse routes:

In this example we pulled a fast one and used the navigate(from:) method, where you can pass a container at once and it becomes the root of the route in the closure.

What if the screen is already in hierarchy?

In case of deeplinks, it is often necessary to show a screen that already exists in the hierarchy. For example, a user opens a chat deeplink, and if it is already displayed on the chat tab, you need to show it, otherwise push it to the top stack. The makeVisible() method is useful for such scenarios:

The last(:) method will add an action to the route to search for the last container in the hierarchy. The search will be done taking into account the chat ID, as we have specified it as a distinctive trait for ChatScreen.

If there is a container in the hierarchy, the makeVisible() method will do its best to make it top: it will close all modal screens on it, recursively switch tabs, reset stack to the desired container. If the screen with the chat you are searching for is not present in the hierarchy, it will fallback to the push in the top stack, and if it cannot be found, it will fallback to the modal presenting route.

There is a problem here: in case of pushing into the stack, you can use the chat screen as it is, but for modal presenting it is better to wrap it in its own stack with a close button in the navbar. This customization is ensured by a separate layer — decorators.

Decorators

Decorators are objects that wrap the module builder and modify the container being created. They correspond to the ScreenDecorator protocol:

All decorators are associated with a container, and the build(screen:navigator:) method accepts a module builder with the same container type. But after building a module, the method may return a different container, for example, the builder creates a UIViewController, and the decorator wraps it in a UINavigationController.

Also, decorators can have data that must be preserved in memory as long as the container is active. For example, a custom modal presentation animation. Such data must be defined in the payload property, which is associated with the container under the hood.

How to decorate?

The default implementation of the Screen protocol contains the decorated(by:) method, which accepts a decorator instance and returns the screen builder, wrapping it in that decorator. This implementation allows you to hang multiple decorators in a row on the same builder.

The decorated(by:) method is useful only for implementing customized decorators, because all the standard ones add convenient methods to the Screen protocol extension. With them, our example of a route for modal chat presenting will look as follows:

If this route is done, the chat container will get a left button in the navbar, then it will be wrapped in a stack container, which in its turn will have a full-screen presentation style. The order of the decorators is important because they are applied to different containers: the left button is added for the ChatViewController, and the modal presentation style is changed for the UINavigationController.

On the final note

Nivelir makes it easy to describe any navigation with the known containers. Something specific is very easy to implement at any level: from navigation actions to logging.

In addition to the basic features discussed in the article, there are many more “sweet” stuff and handy helpers that we continue to take out of our project and work on documentation.

What about stability?

There is no problem with stability, Nivelir has already passed all the checks: production of our apps, a ton of UI tests and weekly regression testing by sharp-eyed QA testers.

How to play?

There is a simple demo project in the repository that you can use as a sandbox, just execute commands in the terminal:

Local navigation and the use of some add-ons are implemented in this project. An example of the implementation of diplinks with authorization has been already presented.

What’s more?

Nivelir can be used for local screen navigation, coordinators, and more advanced solutions for global routing. We’re preparing a separate article about our inter-module navigation architecture and deeplinks. It is going to be fun, stay tuned!

That’s it.

Feedback in the comments would be appreciated. Bye 👋

--

--