Помните времена, когда дизайнеры рисовали незамысловатые интерфейсы, а разработчики просто описывали переходы от одного экрана к другому? Вот и я не помню. Современное iOS-приложение – это тысячи строк кода, где добрая четверть – всего лишь описание навигации. Закономерно, что для упрощения жизни появляются различные фреймворки для навигации.  

Меня зовут Тимур Шафигулин, я – iOS-разработчик в hh.ru. В этой статье я расскажу про фреймворк для навигации в iOS-приложении.

Что такое Nivelir

В предыдущей статье мы уже делали обзор на два решения для навигации – это Badoo и RouteComposer. Они оба не смогли закрыть наши потребности в навигации iOS-приложения. Именно поэтому мы пришли к своему решению и назвали его Nivelir.

Рассмотрим три его основных сущности:

  1. ScreenNavigator навигирует по приложению с вашим UIWindow, ищет контейнеры и логирует действия;

  2. ScreenAction описывает действия навигации: показать экран, положить экран в стек etc.;

  3. Screen описывает, как создается экран и какой контейнер в нем помещается;

Все подробности – в документации, в нашем репозитории.

Краеугольные критерии: первая секция

Первая секция – это удобство работы. Первый критерий в этой секции – локальная навигация. Совершить локальную навигацию с Nivelir максимально просто.

private func showChat(id: Int) {
    screenNavigator.navigate(from: stack) { route in
        route.push(
            ChatScreen(
                roomID: roomID,
                chatID: id
            )
        )
    }
}

Просим ScreenNavigator снавигировать нас из определенного контейнера (в данном примере контейнером является UINavigationController), затем пишем route.push(_:) и кладем новый экран в стек – выглядит просто замечательно. Также Nivelir имеет встроенное расширение для работ с навигациями и контроллерами из UIKit: UIAlertController, UIDocumentInteractionController, UIImagePickerController и другими.

Вот как можно, например, показать ActionSheet (UIAlertController с типом .actionSheet из UIKit) с помощью Nivelir.

private func pickPhotoImage(sender: UIView) {
    let actionSheet = ActionSheet(
        anchor: .center(of: sender),
        actions: [
            ActionSheetAction(title: "Take Photo") {
                self.pickPhotoImageFromCamera()
            },
            ActionSheetAction(title: "Choose Photo") {
                self.pickPhotoImageFromPhotoLibrary()
            },
            .cancel(title: "Cancel")
        ]
    )

    screenNavigator.navigate(from: self) { route in
        route.showActionSheet(actionSheet)
    }
}

Стоит отметить, что ActionSheet и Alert в Nivelir разделены, поскольку имеют отличительные особенности. Важно задать anchor. Он будет использоваться только для iPad, если вы создадите UIAlertController без него, посредством UIKit то вы получите краш в рантайме. Второй параметр actions – это действия. Здесь мы описываем: какие действия должны показаться в нашем ActionSheet. Остается лишь показать этот ActionSheet, используя ScreenAction через Route функцией showActionSheet(_:), и передать туда созданную нами структуру.

Второй критерий – это цепочки открытия. Они в Nivelir бывают разными. Во-первых, последовательными, где мы можем снавигироваться вообще без контейнера (в таком случае контейнером будет являться ключевой UIWindow) и просто пропишем здесь: "найди топовый стек, закрой на нем текущий экран и положи в него новый".

func openSupportScreen() {
    navigator.navigate { route in
        route
            .top(.stack)
            .dismiss()
            .push(screens.supportScreen())
    }
}

Во-вторых, цепочки могут быть вложенными. В этом случае мы ищем UITabBarController, выбираем на нем первый таб с индексом 0 в котором контейнер будет с типом UINavigationController. Уже на этом стэке возвращаемся к корневому экрану с помощью метода popToRoot().

func showRootVacancyScreen() {
    navigator.navigate { route in
        route
            .first(.tabs)
            .selectTab(of: UINavigationController.self, with: .index(0)) { route in
                route.popToRoot()
            }
    }
}

Но вложенные цепочки могут возрастать, следовательно становится тяжелее читать код. В этом случае мы можем выделить отдельные роуты и написать навигацию в них.

let presentChatRoute = ScreenModalRoute
	.initial
	.present(screens.chatScreen(roomID: 1, chatID: 2))

let pushChatRoute = ScreenStackRoute
	.initial
	.push(
		screens.chatScreen(roomID: 1, chatID: 1), 
		route: presentChatRoute
	)

Так, например, мы поделим на два роута – отдельно present и отдельно push. Эти роуты мы уже можем передавать в параметр route.

navigator.navigate { route in
    route
        .first(.tabs)
        .selectTab(
        	of: UINavigationController.self, 
        	with: .index(0), 
        	route: pushChatRoute
        )
}

Таким образом мы выберем таб с типом контейнера UINavigationController, запушим в него экрана чата, затем на запушенном экране модально покажем другой экран чата.

Сами роуты отличаются только по типу контейнера, с которым они работают.

public typealias ScreenModalRoute = ScreenRootRoute<UIViewController>
public typealias ScreenStackRoute = ScreenRootRoute<UINavigationController>
public typealias ScreenTabsRoute = ScreenRootRoute<UITabBarController>
public typealias ScreenWindowRoute = ScreenRootRoute<UIWindow>

ScreenModalRoute может выполнять все действия навигации для UIVievController. А ScreenStakRoute уже сможет, например, сделать push, так как типом его контейнера является UINavigationController. То же самое касается и ScreenTabsRoute и ScreenWindowRoute.

Еще одна отличительная черта Nivelir – изменения стека.

private func showChainNavigation() {
    screenNavigator.navigate(from: stack) { route in
        route
            .push(screens.chatScreen(roomID: roomID, chatID: 1))
            .pop()
            .push(screens.chatScreen(roomID: roomID, chatID: 2))
            .push(screens.chatScreen(roomID: roomID, chatID: 3)) { route in
                route.present(screens.chatScreen(roomID: roomID, chatID: 4))
            }
    }
}

Так мы можем сделать push экрана с чатом номер один, вернуться назад, сделать два пуша для второго и третьего экранов и на третьем показать четвертый. Nivelir склеит анимации изменения стека в одну, что не будет раздражать пользователя – это очень удобно.

Третий критерий – поиск открытого экрана. Давайте попробуем рассмотреть, как создать route, который будет искать открытый экран.

func showChatRoute(roomID: Int, chatID: Int) -> ScreenWindowRoute {
    let screen = chatScreen(roomID: roomID, chatID: chatID)

    return ScreenWindowRoute()
        .last(.container(key: screen.key))
        .makeVisible()
        .refresh()
        .resolve()
}

Для начала нужно создать screen. Важно отметить, что здесь мы еще не создаем UIViewController, поскольку не производим сборку нашего экрана – только инициализируем структуру, которая реализует протокол Screen и является фабрикой для экрана.

Далее описываем route с поиском экрана. Здесь мы создаем ScreenWindowRoute, благодаря которому навигация будет осуществляться в нашем UIWindow. Метод last(_:) будет итерироваться по UITabBarController с конца, а также зайдет в поисках нашего экрана в каждый таб. Если в табе находится UINavigationController, то поиск также производится в нем с конца стека. В случае, если экран будет найден, следующим вызовется метод makeVisible(). Он сделает так, чтобы экран появился перед вашими глазами – переключит таб к найденному экрану, а в стеке вернется к экрану, который нам нужен. После этого вызываем метод refresh(), чтобы обновить данные на экране. В итоге нам остается только собрать наш роут со всеми действиями, описанными выше с помощью метода resolve().

Четвертый критерий – это удобный DSL. Мы приложили массу усилий, чтобы описать навигацию в коде было легкой задачей для разработчиков. Вас будет сопровождать auto-complete на протяжении всего процесса описания навигации. На примере ниже мы описываем поиск UITabBarController и выбираем у него первый таб. После этого отображаем на нем экран с чатом, а поверх покажем еще и UIAlertController.

Пятый критерий – это строгость типизации. Мы постарались сделать навигацию строготипизированной насколько это возможно. Пример: у нас есть ScreenNavigator, и мы хотим снавигироваться в контейнере с типом UIVievController.

private let container: UIViewController?

private func showChainNavigation() {
    screenNavigator.navigate(from: container) { route in
    		// ❌ Referencing instance method 'push(_:animation:separated:)' 
        // on 'ScreenThenable' requires that 'UIViewController' 
        // inherit from 'UINavigationController'
        route.push(screens.chatScreen(roomID: roomID, chatID: 1)) 
    }
}

Разумеется, мы не сможем сделать действие push(_:), поскольку контейнер не является стеком навигации. Для этого у контейнера мы можем использовать переменную stack и получить его UINavigationController. Но, если контейнер nil или стека не было найдено, то Nivelir распечатает вам ошибку в консоли, о том что навигация не может быть осуществлена.

Передача данных между экранами происходит через фабрики (через скрины), поэтому здесь тоже все строго типизировано и удобно.

Шестой критерий – это кастомные анимации. В Nivelir можно анимировать смену корневого экрана, изменение стека навигации и модальный показ – всё необходимое для современного iOS-приложения. В Nivelir уже есть заготовленные анимации, но вам ничто не помешает их расширить, реализовав только соотвествующие протоколы для каждого типа анимации. Для кастомной анимации смены корневого экрана нужно будет реализовать протокол ScreenRootCustomAnimation, для изменения стека ScreenStackCustomAnimation, для модального показа UIViewControllerTransitioningDelegate из UIKit.

Краеугольные критерии: вторая секция

Вторая секция – граф навигации. Первый критерий здесь – обработка ошибок. В критерии «поиск открытого экрана» мы уже искали экран с нужным roomID и chatID, и, если экран не был найден, мы не совершали никакую навигацию, а получали только ошибку в консоль.

Попробуем добавить альтернативную навигацию: напишем метод fallback, и уже в нем зафиксируем, что будем делать, если экран не найдется.

func showChatRoute(roomID: Int, chatID: Int) -> ScreenWindowRoute {
    let screen = chatScreen(roomID: roomID, chatID: chatID)

    return ScreenWindowRoute()
        .last(.container(key: screen.key))
        .makeVisible()
        .refresh()
        .fallback(
            to: showChatListRoute(roomID: roomID)
                .top(.stack)
                .push(screen)
        )
}

Так мы покажем экран со списком чатов и в нем же запушим новый экран с чатом. Кроме того, мы сможем обрабатывать ошибки разных типов. Например, если будем использовать MediaPicker из Nivelir, который показывает UIImagePickerController из UIKit, то в методе fallback мы сможем совершать различные навигации по типу ошибки.

let mediaPicker = MediaPicker(source: .camera) { ... }

screenNavigator.navigate(from: self) { route in
    route
        .showMediaPicker(mediaPicker)
        .fallback { error, route in
            switch error {
            case is MediaPickerSourceAccessDeniedError:
                return route.showAlert(.cameraPermissionRequired)

            case is UnavailableMediaPickerSourceError:
                return route.showAlert(.unavailableMediaSource)
              
            ...
            }
        }
}

Второй критерий – это интерсепторы. Важно отметить, что интерсепторы – это и есть действие навигации в Nivelir. Проще говоря, здесь нет какого-то отдельного протокола, это тот же ScreenAction. Для наглядности покажу, как создать, например, интерсептор для авторизации.

Объявим структуру ScreenAutorizeAction, он наследуется от протокола ScreenAction. Здесь мы поместим DI-контейнер для сервисов и экранов, а непосредственно в методе perform(container:,navigator:,completion:) уже будем выполнять нашу логику.

struct ScreenAuthorizeAction<Container: UIViewController>: ScreenAction {

    typealias Output = Container

    let services: ScreenAuthorizeActionServices
    let screens: ScreenAuthorizeActionScreens

    init(...) { ... }

    func perform(
        container: Container,
        navigator: ScreenNavigator,
        completion: @escaping Completion
    ) {
        navigator.logInfo("Checking authorization")

        if services.authorizationService().isAuthorized {
            completion(.success(container))
        } else {
            navigator.navigate(
                to: screens.showAuthorizationRoute { isAuthorized in
                    if isAuthorized {
                        completion(.success(container))
                    } else {
                        completion(.failure(ScreenCanceledError(for: self)))
                    }
                }
            )
        }
    }
}

Таким образом мы сделаем логирование о том, что мы проверяем авторизацию. Если пользователь уже авторизован, то мы сразу вернем completion с текущим контейнером, на котором производим навигацию. В ином случае покажем экран авторизации, и если пользователь авторизовался, то все хорошо. А если он отменил авторизацию или произошла какая-то ошибка, мы просто вернем дженерную ошибку ScreenCanceledError.

Чтобы использовать интерсепторы удобнее, мы можем написать extension вот в таком виде.

extension ScreenThenable where Current: UIViewController {
    func authorize(
        services: ScreenAuthorizeActionServices,
        screens: ScreenAuthorizeActionScreens
    ) -> Self {
        then(
            ScreenAuthorizeAction<Current>(
                services: services,
                screens: screens
            )
        )
    }
}

В итоге мы используя навигатор и описывая наш route, добавляем authorize(services:,screens:), а затем – логику навигации, которая требует авторизации.

navigator.navigate(from: self) { route in
    route
        .authorize(services: services, screens: screens)
        .present(screens.chatListScreen(roomID: roomID).withStackContainer())
}

Третий критерий – это диплинки. В Nivelir мы поддерживаем диплинки из коробки, есть диплинк-менеджер, который будет отвечать за их навигацию.

DeeplinkManager(
    deeplinkTypes: [
        ChatDeeplink.self
    ],
    navigator: screenNavigator()
)

У него есть типы диплинков и навигатор. С навигатором мы уже знакомы, а типы диплинков мы сейчас рассмотрим.

Чтобы менеджер смог обрабатывать ваши диплинки и совершать навигацию, его необходимо активировать. Ведь когда ваше приложение еще закрыто – оно не в состоянии совершать навигацию, и мы должны подать сигнал, когда будем готовы. Это можно сделать, например, в методе viewDidAppear у UITabBarController. После активации, если были какие-то отложенные диплинки, то начнется их выполнение.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    deeplinkManager.activate(screens: screens)
}

Посмотрим как собрать свой deeplink. Например deeplink на открытие экрана чата, ChatDeelLink – для его показа нужны roomID и chatID.

struct ChatDeeplink: Deeplink {

    let roomID: Int
    let chatID: Int

    func navigate(
        screens: Screens,
        navigator: ScreenNavigator,
        handler: DeeplinkHandler
    ) throws {
        navigator.navigate(
            to: screens.showChatRoute(
                roomID: roomID,
                chatID: chatID
            )
        )
    }
}

Здесь мы только реализуем протокол Deeplink и реализуем одну функцию navigate. Описываем навигацию, которая должна быть совершена для этого диплинка.

Опишем данные для этого диплинка – ChatDeeplinkPayLoad, обычная Decodable структура, которая содержит данные необходимые для открытия диплинка.

struct ChatDeeplinkPayload: Decodable {
    enum CodingKeys: String, CodingKey {
        case roomID = "room_id"
        case chatID = "chat_id"
    }

    let roomID: Int
    let chatID: Int
}

Но как добавить поддержку открытия url, пушей или шорткатов? Все они отличаются. Мы можем потихоньку расширять наш ChatDeeplink и добавить ему, например, поддержку url.

extension ChatDeeplink: URLDeeplink {

    static func url(
        scheme: String?,
        host: String?,
        path: [String],
        query: ChatDeeplinkPayload?,
        context: Any
    ) throws -> ChatDeeplink? {
        guard let payload = query, scheme == "nivelir", host == "chat" else {
            return nil
        }

        return Self(roomID: payload.roomID, chatID: payload.chatID)
    }
}

Так мы реализуем метод url, и в нем нам уже приходит разобранный url. Здесь стоит отметить два параметра: query и context. Если ваш тип реализует протокол Decodable, то query часть ссылки будет декодировано в вашу структуру автоматически, Nivelir все сделает за вас, чтобы не приходилось разбирать этот query: доставать отдельные компоненты (схему, хост, параметры и тп), кастить их в нужный формат и так далее. 

Остается только проверить, соответствует ли схема и хост, и после этого создать наш ChatDeeplink. Теперь нам остается лишь вызвать у диплинк-менеджера метод handleURLIfPossible(_:context:), и отдать наш URL.

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    switch userActivity.activityType {
    case NSUserActivityTypeBrowsingWeb:
        guard let url = userActivity.webpageURL else {
            return
        }
        
        services?
            .deeplinkManager()
            .handleURLIfPossible(url, context: services)

    default:
        break
    }
}

Контекстом может служить любой DI-контейнер для сервисов. Nivelir поддерживает и другие типы, вы можете продолжать расширять ваш диплинк, подписав под нужные протоколы – NotificationDeeplink для push-уведомлений и ShortcutDeeplink для shortcuts.

Краеугольные критерии: третья секция

Третья и последняя секция – это масштабируемость. Первый критерий в ней – многомодульность. При разработке Nivelir мы сразу думали, как будем совершать навигацию в многомодульном приложении, поскольку используем в нашем приложении фиче-модули. И вот как мы можем шарить экраны между ними. 

Для этого у нас есть DI-контейнер с экранами, он возвращает нам скрины. Но скрин ассоциирован с определенным типом контейнера, который необходимо стереть. Для этого мы будем пользоваться методами eraseToAnyScreen, который стирает тип к нужному контейнеру.

struct Screens {
    func homeScreen() -> AnyTabsScreen {
        HomeScreen(
            services: services,
            screens: self
        ).eraseToAnyScreen()
    }

    func roomListScreen() -> AnyModalScreen {
        RoomListScreen(
            services: services,
            screens: self
        ).eraseToAnyScreen()
    }
}

Итак, у нас есть AnyModalScreen, AnyStakScreen и AnyTabsScreen.

public typealias AnyModalScreen = AnyScreen<UIViewController>
public typealias AnyStackScreen = AnyScreen<UINavigationController>
public typealias AnyTabsScreen = AnyScreen<UITabBarController>

Все они отличаются только по типу контейнера, который содержится в скрине. Например, для AnyStakScreen мы сможем сделать push, а для AnyTabsScreen можно переключить tab. Также мы можем хранить роуты в DI-контейнере, а не только экраны, таким образом переиспользуя навигацию между фиче-модулями.

struct Screen {

    func showHomeRoute() -> ScreenWindowRoute {
        ScreenWindowRoute()
            .setRoot(to: homeScreen(), animation: .crossDissolve)
            .makeKeyAndVisible()
    }

    func showRoomListRoute() -> ScreenWindowRoute {
        ScreenWindowRoute()
            .last(.container(key: roomListScreen().key))
            .makeVisible()
            .fallback(to: showHomeRoute())
    }
}

Второй критерий – постепенная миграция. Чтобы использовать все возможности Nivelir ваши фабрики должны соответствовать протоколу Screen. Для этого мы рекомендуем построить дерево зависимости между экранами и начать рефакторить от листового к корневому – так будет проще и удобнее постепенно переходить на Nivelir.

Заключение

С Nivelir мы сделали такой фреймворк навигации, который закрывает все наши краеугольные критерии для удобной навигации в iOS-приложении. Также Nivelir поддерживает и tvOS, как бонус.

Все примеры кода вы найдете в нашем репозитории. Там же вы сможете ознакомиться с документацией и другими возможностями Nivelir. Пишите любые вопросы в комменты, я буду рад на них ответить.

Комментарии (1)


  1. K1aiman
    03.06.2022 09:26

    Если честно, выглядит не красиво. метод Navigate подразумевает переход куда-то. но не вызов кложуры, в которой есть route и руками переходите сами. Выглядит как обертка над оберткой