Nivelir: a convenient DSL for navigation
Scalable and composable routing for iOS apps
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
:
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:
protocol Screen { associatedtype Container: ScreenContainer var name: String { get }
var traits: Set<AnyHashable> { get } func build(navigator: ScreenNavigator) -> Container
}
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:
extension Screen { var key: ScreenKey {
ScreenKey(name: name, traits: traits)
}
}
This screen key can be used to find the container in the hierarchy if it implements the ScreenKeyedContainer
protocol:
protocol ScreenKeyedContainer: ScreenContainer {
var screenKey: ScreenKey { get }
}
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:
class ChatViewController: UIViewController, ScreenKeyedContainer { let chatID: Int
let screenKey: ScreenKey
let screenNavigator: ScreenNavigator init(
chatID: Int,
screenKey: ScreenKey,
screenNavigator: ScreenNavigator
) {
self.chatID = chatID
self.screenKey = screenKey
self.screenNavigator = screenNavigator super.init(nibName: nil, bundle: nil)
} // ...
}
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:
struct ChatScreen: Screen { let chatID: Int var traits: Set<AnyHashable> {
[chatID]
} func build(navigator: ScreenNavigator) -> UIViewController {
ChatViewController(
chatID: chatID,
screenKey: key,
screenNavigator: navigator
)
}
}
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:
class SimpleViewController: UIViewController, Screen { … }
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:
typealias AnyModalScreen = AnyScreen<UIViewController>
typealias AnyStackScreen = AnyScreen<UINavigationController>
typealias AnyTabsScreen = AnyScreen<UITabBarController>
So the method that returns the chat module builder might look like this:
func chatScreen(chatID: Int) -> AnyModalScreen {
ChatScreen(chatID: chatID).eraseToAnyScreen()
}
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:
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow?
var navigator: ScreenNavigator? func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else {
return
} let window = UIWindow(windowScene: windowScene)
let navigator = ScreenNavigator(window: 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:
protocol ScreenAction { associatedtype Container: ScreenContainer
associatedtype Output func perform(
container: Container,
navigator: ScreenNavigator,
completion: @escaping (Result<Output, Error>) -> Void
) func combine<Action: ScreenAction>(
with other: Action
) -> AnyScreenAction<Container, Void>?
}
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:
protocol ScreenStackModifier { func perform(
stack: [UIViewController],
navigator: ScreenNavigator
) throws -> [UIViewController]
}
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:
func perform(
stack: [UIViewController],
navigator: ScreenNavigator
) throws -> [UIViewController] {
stack + [screen.build(navigator: navigator)]
}
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:
protocol ScreenThenable { associatedtype Root: ScreenContainer
associatedtype Current: ScreenContainer var actions: [AnyScreenAction<Root, Void>] { get } func then<Action: ScreenAction>(
_ action: Action
) -> Self where Action.Container == Current func then<Route: ScreenThenable>(
_ other: Route
) -> Self where Route.Root == Current
}
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:
extension ScreenThenable where Current: UIViewController { func dismiss(animated: Bool = true) -> Self {
then(ScreenDismissAction<Current>(animated: animated))
}
}
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:
typealias ScreenModalRoute = ScreenRootRoute<UIViewController>
typealias ScreenStackRoute = ScreenRootRoute<UINavigationController>
typealias ScreenTabsRoute = ScreenRootRoute<UITabBarController>
typealias ScreenWindowRoute = ScreenRootRoute<UIWindow>
The most common of these aliases is ScreenWindowRoute
. Its instance starts the description of the main route. For example:
let route = ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
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:
let pushRoute = ScreenStackRoute()
.push(ChatScreen(chatID: 123))let route = ScreenWindowRoute()
.top(.stack)
.then(pushRoute)
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:
func chatRoute(
chatID: Int
) -> ScreenRoute<UIWindow, UINavigationController> {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
}
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:
func chatRoute(chatID: Int) -> some ScreenThenable {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
}
A less convenient but universal solution is to make the route be root by adding a invoke to the resolve()
method at the end:
func chatRoute(chatID: Int) -> ScreenWindowRoute {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
.resolve()
}
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:
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))navigator.navigate(to: 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:
let screen = ChatScreen(chatID: 123)let presentRoute = ScreenWindowRoute()
.top(.container)
.present(screen)let pushRoute = ScreenWindowRoute()
.top(.stack)
.push(screen)
.fallback(to: presentRoute)
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:
let route = ScreenWindowRoute()
.from(container)
.stack
.push(ChatScreen(chatID: 123))navigator.navigate(to: route)
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:
navigator.navigate(from: container) { route in
route.stack.push(ChatScreen(chatID: 123))
}
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:
let screen = ChatScreen(chatID: 123)let pushRoute = ScreenWindowRoute()
.top(.stack)
.push(screen)let presentRoute = ScreenWindowRoute()
.top(.container)
.present(screen)let route = ScreenWindowRoute()
.last(.container(of: screen))
.makeVisible()
.fallback(to: pushRoute)
.fallback(to: presentRoute)
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:
protocol ScreenDecorator { associatedtype Container: ScreenContainer
associatedtype Output: ScreenPayloadedContainer var payload: Any? { get } func build<Wrapped: Screen>(
screen: Wrapped,
navigator: ScreenNavigator
) -> Output where Wrapped.Container == Container
}
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:
let presentRoute = ScreenWindowRoute()
.top(.container)
.present(
screen
.withLeftBarButton(closeBarButton)
.withStackContainer()
.withModalPresentationStyle(.fullScreen)
)
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:
git clone https://github.com/hhru/Nivelir.git
cd Nivelir/Example
pod install
open NivelirExample.xcworkspace
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 👋