Managing navigation in iOS applications. Pattern coordinator from SberMarket

The standard way to set up navigation in an iOS app is to use the UIViewController class. It works until you need to add new screens or swap them. Complex transition logic is best built with the help of coordinators.

Under the cut, we tell how and why we in the team wrote our own implementation of the Coordinator pattern.

This article is a text version of Philip Krasnovid’s speech at iOS Meetup by SberMarket Tech.

What are coordinators

A coordinator is a special class that contains the navigation logic between screens in an application. The idea of ​​this pattern described by Soroush Hanlow in 2015.

Usually, the UIViewController class is responsible for navigation in iOS. It controls the screens, the transitions between them, and the response to user actions. The class has standard ways to customize navigation:

  • register screens and flows (segues) between them in the Storyboard file, and then call them in the right places in the code;

  • use containers like UINavigationController;

  • call the screen directly via the present(_:animated:completion:) method.

Everything is fine as long as we show the screens in a strictly defined order. Difficulties begin if you need to change the order of the screens, add a new transition, or transfer data from the last screen to the first.

The problem is in the implementation of the UIViewController class itself. All screens called in the class object are hard-wired to the parent. The first screen must be aware of the existence and type of each child screen it calls. You can read more about this in the analysis of Pavel Gurov.

You can solve the problem if you remove the navigation logic from the UIViewController. To do this, you need to remove from the class object all initializations and calls to other screens, data transfer, do not use streams and containers. And move the logic to a new Coordinator class, whose objects will be responsible for calling screens in the application. Then the screens will not need to “know” in what order they go and to whom they transmit what data.

Approximate scheme of work of coordinators.  The AppCoordinator component is responsible for the main coordinator, authorization screens and onboarding
Approximate scheme of work of coordinators. The AppCoordinator component is responsible for the main coordinator, authorization screens and onboarding

We could take one of the many ready-made implementations coordinators, but instead decided to file their own.

Why we created our own implementation of coordinators

Our team needed to solve several problems:

Get rid of the boilerplate code. I wanted to remove long pieces of code from the application and make it more concise. Most of the foreign coordinators turned out to have boilerplate areas that we don’t need.

Stop controlling navigation manually. We are tired of tracking the life cycle of screens. It is much more convenient to transfer this task to the coordinator.

Remove the human factor. All developers make mistakes sometimes. We wanted to leave as few opportunities as possible for us to do something wrong.

To solve all the problems at once, we wrote our own version of the pattern.

What is the difference between the implementation of SberMarket coordinators and the standard one

We wrote several new protocols and improved the standard ones a bit.

Transition- new protocol to work with animators in the NavigationController and set up animations for transitions between screens and tabs.

LyfeCycleListener- a new protocol that keeps track of navigation events. The functions in it work by analogy with the NavigationController functions:

  • increment and decrement are analogues of pop and push;

  • startNotify – set the root controller in the navigation stack;

  • dismissNotify – dismiss any modal screen;

  • toRootNotify – Handling the pop-to-root event when the root controller needs to be shown on the stack.

SystemNavigation — a new protocol for working with standard navigation events from UIkit.

/// Абстракция от UIKit'a
public protocol Transition: AnyObject {
    var transitioning: UIViewControllerAnimatedTransitioning { get }
/// Интерфейс слушателя жизненного цикла юнитов в координаторах
public protocol LifeCycleListener: AnyObject {
    func increment()
    func decrement()
    func startNotify()
    func dismissNotify(event: ApplicationRouter.RouterEvent) 
    func toRootNofity(in router: Routable)
/// Интерфейс для сущности UINavigationController в системе
public protocol SystemNavigation: UINavigationController {
    var popToRootHandler: (() -> Void)? { get set }
    var popHandler: (() -> Void)? { get set }

Implementations of the Transition, LifeCycleListener and SystemNavigation protocols

For our implementation, we rewrote two classes: BaseCoordinator and ApplicationRouter.

BaseCoordinator – the main class that keeps track of dependencies between screens. It still has standard methods: adding and removing dependencies, an array of child coordinators.

/// Базовый класс для координатора
open class BaseCoordinator {
    public let router: Routable
    private weak var parentCoordinator: BaseCoordinator?
    private let listener = DefaultLifeCycleListener()
    private var childCoordinators: [BaseCoordinator] = []
    public private(set) var countUnits: Int = 0 {
        didSet {
            assert(countUnits >= 0, "Что-то пошло не так!")
            if countUnits == 0 { parentCoordinator?.removeChild(self) }
    public init(router: Routable, parent: BaseCoordinator? = nil) {
        self.parentCoordinator = parent
        self.router = router
        self.listener.recieveEvent = { ... }

Adding and removing dependencies is encapsulated in BaseCoordinator.

To manage dependencies, we use the countUnits counter. It shows how many units are currently dependent on the parent coordinator.

The parentCoordinator property was also added here – a link to the parent coordinator. It is needed in order to remove and add the current coordinator to the dependency.

The listener field is a call to the LyfeCycleListener protocol, a message interface from the router.

Application Router – a class for working with a router. The router handles navigation events and reports them to the coordinator. ApplicationRouter uses three protocols:

  • Routable,

  • UINavigationControllerDelegate,

  • UIAdaptivePresentationControllerDelegate.

We supplemented the standard Routable protocol with the method subscribe. It sends a navigation event message to the coordinator.

/// Интерфейс роутер для системы координаторов
public protocol Routable: AnyObject {
    func pushModule(_ module: Presentable, transition: Transition?, ....)
    func setRootModule(_ module: Presentable, transition: Transition?, ...)
    func popModule(transition: Transition?, animated: Bool, comletion: (() -> Void)?)
    func popToRootModule(animated: Bool, completion: (() -> Void)?)
    func presentModule(_ module: Presentable, ....)
    func dismissModule(animated: Bool, completion: (() -> Void)?)
    func closeModule(animated: Bool, transition transitionIfCan: Transition?, ...)
    func subscribe(_ listener: LifeCycleListener)

UINavigationControllerDelegate is needed to support animation of transitions between screens. It also handles swipe-to-back, that is, closing the swipe from the edge of the screen.

UIAdaptivePresentationControllerDelegate handles events from modal views that are not full screen.

How we use coordinators

The navigation scheme in the SberMarket app is generally quite simple:

We have a root ApplicationCoordinator that starts when the application starts. It contains three service coordinators that perform different checks: authorization, history, onboarding.

When the application is ready to go, one of the service coordinators calls the TabBarCoordinator. It manages the coordinators of the application’s five tabs:

  • MainTabCoordinator (Main),

  • CatalogTabCoordinator (Catalog),

  • CartTabCoordinator (Cart),

  • FavoritesTabCoordinator (Favorite),

  • ProfileTabCoordinator (Profile).

Tabs in the SberMarket app
Tabs in the SberMarket app

Each tab has its own screens, where navigation is also built on controllers, but we will not talk about them in detail. Thanks to the coordinators, we reduced the amount of code for calling screens by 2–4 times.

func openLoginFlow() {
    let (coordinator, presentable) = coordinatorsFactory.makeLoginCoordinator()
    coordinator.output = LoginCoordinatorOutput(onFinish: { [weak self, weak coordinator] reason in
            let strongCoordinator = coordinator,
            let self = self
        else { return }
        switch reason {
        case .close: break
        case .success, .closeConfirmationPhoneFlow:
        self.router.dismissModule(animated: true)
    router.present(presentable, animated: true)
    coordinator.start(with: .login(source: .favouriteList), animated: false)

Previously: display code for one coordinator with authorization

func openLoginFlow() {
  	let unit = coordinatorsFactory.makeLoginCoordinator(output: self)
    unit.coordinator.start(with: .login(source: .favouriteList))

New: the same call, but in a new implementation

Before we started using our own implementation of coordinators, there was a captcher list in the code, dependencies had to be added and removed manually. It got a lot and confusing.

With the coordinators, almost all calls began to take up three lines, it turned out to be compact and understandable. In rare cases, there may be 4–5 lines if we write additional properties when initializing the coordinator. How the implementation works can be understood from the example below.

The life cycle of the coordinator on the example of UINavigationController-stack
The life cycle of the coordinator on the example of UINavigationController-stack
  1. We initialize the coordinator.

  2. The subscribe() method is called in the initializer – a subscription to messages from the router.

  3. We start the coordinator by calling the start method.

  4. Inside the start() method, a module is created that needs to be shown on the screen. We push it using the pushModule method on the router.

  5. The router sends an increment-event to the coordinator.

  6. The coordinator receives an event from the router and checks the countUnits. countUnits == 0. The addChild() method is called on the parent coordinator and the new coordinator is added as a dependency.

  7. The countUnits counter, which was originally 0, is now 1.

  8. Once again, we create and push the module through the pushModule method.

  9. The router again sends increment to the coordinator.

  10. countUnits is now 2.

We have displayed everything that was needed on the screen and now we close the modules by calling the pop method.

  1. First we close the module that was displayed last – it is the top one in the navigation stack.

  2. The router sends a decrement to the coordinator.

  3. The coordinator decrements countUnits by one.

  4. Again, the pop method closes the top module on the stack.

  5. The router sends decrement.

  6. countUnits == 0, so the coordinator removes itself from the parent coordinator.

  7. The new coordinator doesn’t hold anything anymore, so he’s deallocated.

The main thing we got from the implementation of coordinators:

  • removed unnecessary code from the project, such as captcha-lists, boilerplate-code;

  • stopped manually monitoring the life cycle of the coordinator, because he himself counts the number of dependencies and is removed;

  • transferred the processing of system events and swipes across the screen to a special protocol.

All together, this gave a smaller number of errors in the code and simplified the life of developers.

We started social networks with news and announcements of the Tech-team. If you want to know what’s under the hood of high-load e-commerce, follow us where it’s most convenient for you: Telegram, VK.

Similar Posts

Leave a Reply