Nivelir: Удобный DSL для навигации
Год назад мы пересмотрели свою реализацию роутинга в iOS-приложениях hh.ru. Тогда она больше походила на простой слой сборки экранов, чем на роутинг как таковой. Смирившись с этим печальным фактом, мы принялись исследовать тему навигации: пересмотрели много подходов в iOS, примерили каждое в песочнице нашего проекта и даже дошли до Cicerone из мира Android.
Взяв лучшее из всех изученных решений, мы переработали всё это дело в собственную реализацию, которая теперь идеально подходит под наши требования к навигации. Недавно мы начали выносить свои наработки в отдельный open-source проект — Nivelir. Эта статья поможет в нём разобраться и покажет, как устроен роутинг в наших проектах.
А каким должен быть роутинг?
Нам хотелось отделить описание навигации от её выполнения, чтобы единожды объявленный переход можно было переиспользовать и для выполнения навигации, и для объявления другого перехода. Так мы выявили ряд основных требований к нашему роутингу:
- Композируемость: сложная навигация должна строиться из простых, понятных и переиспользуемых сущностей.
- Удобство: не имеет смысла использовать роутинг, синтаксис которого сложнее и объёмнее стандартных средств.
- Масштабируемость: роутинг должен предоставлять достаточный уровень абстракции для добавления новой функциональности.
- Универсальность: у нас много старых экранов на разных унаследованных архитектурах (MVC, VIPER), и от роутинга требуется поддержка навигации к любому такому экрану.
- Строгая типизация: разработчики должны видеть свои ошибки на этапе компиляции, чтобы навигация была полностью безопасной в рантайме.
Увы, стандартными средствами невозможно описать навигацию отдельно от её выполнения. Объекты UIStoryboardSegue не в счёт, они используются только в сторибордах, а передачу данных между экранами в этом случае сложно назвать удобной и безопасной. Плюс ко всему из коробки отсутствует возможность композиции объектов навигации в любом виде.
Наши требования к навигации удалось реализовать созданием ёмкого DSL, который может быть интересен и другим проектам. В любом случае, чужой опыт всегда полезен, поэтому в статье много деталей реализации базовой структуры, с неё и начнём обзор.
Базовая структура
Nivelir базируется на 6 основных сущностях:
- Контейнер экрана: любой объект, способный выполнять навигацию, простейший пример —
UIViewController
. - Сборщик модуля: абстракция для сборки модуля экрана, будь то MVVM, VIPER или MVC.
- Навигатор: точка входа для выполнения действий навигации.
- Действия навигации: простейшие операции, которые используются самой навигацией или другими действиями.
- Роуты: описание навигации в виде набора действий.
- Декораторы: обёртки над сборщиком модуля, изменяющие создаваемый им контейнер.
Вкратце общая схема выглядит так: навигатору на вход поступает набор действий навигации в виде роута, эти действия выполняются в определённых контейнерах и могут создавать новые с помощью сборщика модуля. При этом создаваемые контейнеры могут быть специализированы по месту использования с помощью декораторов.
Кроме этих высокоуровневых сущностей есть также внутренние слои. По мере рассмотрения базы их мы тоже затронем более детально.
Контейнер экрана
Любая навигация в Nivelir выполняется в так называемых «контейнерах экрана», для UIKit можно разделить их на несколько типов:
- Оконный контейнер — это экземпляр
UIWindow
. То есть у любого экрана есть окно, в котором он отображается. - Контейнер табов — экземпляр
UITabBarController
. - Контейнер стека — экземпляр
UINavigationController
. - Модальный контейнер — любой экземпляр
UIViewController
, который может быть использован для модального отображения другого экрана.
Так как и UITabBarController
, и UINavigationController
— наследники UIViewController
, они также выступают и модальными контейнерами.
В коде контейнеру соответствует пустой протокол ScreenContainer
:
protocol ScreenContainer { }
И UIWindow
, и UIViewController
и, соответственно, все его наследники подписаны под этот протокол. Но чтобы использовать эти контейнеры, их нужно где-то получить — это уже ответственность сборщика модуля.
Сборщик модуля
Хорошим тоном считается выделение отдельной сущности для сборки модуля экрана. В ней внедряются внешние зависимости, создаются слои модуля и связываются между собой. Результатом выполнения сборки является контейнер для навигации, в случае UIKit — это чаще всего контроллер.
Nivelir определяет протокол Screen
для таких сборщиков:
protocol Screen {
associatedtype Container: ScreenContainer
var name: String { get }
var traits: Set { get }
func build(navigator: ScreenNavigator) -> Container
}
Каждый сборщик ассоциирован с типом создаваемого им контейнера. На практике это обычно UIViewController
, но возможны модули и c UINavigationController
или UITabBarController
в качестве контейнера.
В методе build(navigator:)
необходимо реализовать сборку модуля и вернуть контейнер соответствующего типа. Для собственной навигации экрана следует использовать экземпляр навигатора, который передаётся в параметре navigator
.
У каждого экрана есть имя, которое определено в свойстве name
. Реализация по умолчанию протокола Screen
возвращает название своего типа для этого свойства, и нет необходимости определять его самостоятельно.
Свойство traits
определяет набор отличительных признаков экрана. Например, если в стеке находится два экрана чата с разными собеседниками, то их отличительным признаком будет идентификатор чата.
Зачем экрану имя и отличительные признаки?
Из свойств name
и traits
формируется ключ экрана в свойстве key
, который определен в расширении протокола Screen
:
extension Screen {
var key: ScreenKey {
ScreenKey(name: name, traits: traits)
}
}
Этот ключ экрана может быть использован для поиска контейнера в иерархии, если он реализует протокол ScreenKeyedContainer
:
protocol ScreenKeyedContainer: ScreenContainer {
var screenKey: ScreenKey { get }
}
Достаточно подписать контейнер под этот протокол и объявить свойство screenKey
, значение которого можно передать из сборщика модуля.
Как выглядит реализация?
Для примера возьмём простой экран чата, который принимает только один внешний параметр — идентификатор чата. Тогда его контроллер будет иметь такой вид:
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)
}
// ...
}
Контроллер соответствует протоколу ScreenKeyedContainer
, следовательно его можно будет найти в иерархии. Кроме ключа экрана, инициализатор принимает экземпляр навигатора, который контроллер будет использовать для собственной навигации.
Реализация сборщика модуля этого экрана будет выглядеть так:
struct ChatScreen: Screen {
let chatID: Int
var traits: Set {
[chatID]
}
func build(navigator: ScreenNavigator) -> UIViewController {
ChatViewController(
chatID: chatID,
screenKey: key,
screenNavigator: navigator
)
}
}
В этом примере было бы удобнее, если бы сборщик соответствовал протоколу Equatable
, вместо ручного определения идентификатора чата в свойстве traits
. Тогда для поиска контейнера можно было бы использовать сам экземпляр сборщика модуля. Но в реальных проектах в его полях будет много других сущностей, не соответствующих протоколу Equatable
. Например, сервисы.
В этом случае пришлось бы писать реализацию сравнения вручную, а вынуждать выделять отдельную структуру для каждого экрана с его данными нам тоже не хотелось, поэтому был придуман удобный компромисс в виде свойства traits
.
А если сборщик не нужен?
Для совсем простых экранов, состоящих только из контейнера, отдельный сборщик может быть избыточным. На этот случай можно подписать сам контроллер под протокол Screen
:
class SimpleViewController: UIViewController, Screen { ... }
Тогда реализация по умолчанию протокола Screen
вернёт себя в методе build(navigator:)
, а экземпляр этого контроллера можно будет использовать в действиях навигации.
Как стереть тип сборщика?
Так как сборщик модуля ассоциирован с типом контейнера, его невозможно использовать по типу протокола Screen
. Чтобы не передавать сборщик по конкретному типу, можно воспользоваться структурой AnyScreen
.
Для удобства в реализации по умолчанию любого сборщика присутствует метод eraseToAnyScreen()
, который оборачивает экземпляр в AnyScreen
. А чтобы конструкции были максимально компактными, есть алиасы для AnyScreen
под тип его контейнера:
typealias AnyModalScreen = AnyScreen
typealias AnyStackScreen = AnyScreen
typealias AnyTabsScreen = AnyScreen
Таким образом метод, возвращающий сборщик модуля чатов, может выглядеть так:
func chatScreen(chatID: Int) -> AnyModalScreen {
ChatScreen(chatID: chatID).eraseToAnyScreen()
}
На этом собирать экраны научились, теперь пора разобраться со структурой навигатора.
Навигатор
Навигатор служит точкой входа для роутинга. Он выполняет действия навигации, логирует их ошибки и сообщения, ищет контейнеры в иерархии окна. Но всё это он делает не сам, а делегирует трём составляющим:
ScreenWindowProvider
— предоставляет окно для навигации.ScreenIterator
— итерирует в иерархии для поиска контейнера.ScreenLogger
— логирует события навигации.
Каждую из этих сущностей можно заменить на собственную реализацию, но в этом чаще всего нет необходимости: стандартному инициализатору навигатора достаточно передать только экземпляр окна в параметре window
.
Предполагается, что на каждый экземпляр UIWindow
в приложении будет свой навигатор, поэтому лучше создавать его вместе с окном:
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)
// ...
}
}
Действия навигации вызывают метод build(navigator:)
сборщика модуля с тем экземпляром навигатора, который их и выполняет. Модуль должен использовать полученный навигатор для собственного роутинга — это необходимо, поскольку навигация часто связана с поиском контейнера, и важно гарантировать, что этот поиск производится в иерархии правильного окна.
Большинство методов навигатора наиболее полезны для реализации действий навигации, поэтому сначала разберёмся с ними, а потом посмотрим на примеры использования.
Действия навигации
Самый масштабируемый слой в Nivelir — это как раз действия навигации, и для них мы выработали ряд свойств. Действия могут:
- Выполнять переходы, например, present, push и т.д.
- Вообще не менять иерархию экранов, например, искать контейнер.
- Выполнять набор других действий.
- Объединяться с другими действиями.
Все действия навигации соответствуют протоколу ScreenAction
:
protocol ScreenAction {
associatedtype Container: ScreenContainer
associatedtype Output
func perform(
container: Container,
navigator: ScreenNavigator,
completion: @escaping (Result
Этот протокол ассоциирован с типом контейнера, к которому применяется действие, и с типом возвращаемого значения. Метод perform
принимает контейнер и возвращает результат выполнения, вызывая замыкание completion
.
Чтобы действия могли объединяться в требованиях протокола присутствует метод combine
, который возвращает новое действие со стёртым типом в случае успешного объединения. Но объединяться могут не все действия, поэтому реализация по умолчанию этого метода возвращает nil
.
Зачем объединять действия?
Нередко в приложениях приходится перестраивать целый стек экранов, например, когда пользователь открывает диплинк. Предположим, что нам нужно выполнить три действия по такому диплинку:
- Найти стек в табе чатов
- Сбросить стек до списка чатов
- Запушить в стек экран чата
Если выполнять эти действия изолированно, то в ситуации, когда в стеке находится больше одного экрана, пользователь увидит двойную анимацию. Сначала возврат на список чатов, затем пуш нового экрана:
Намного приятнее объединить эти действия в единый вызов метода setViewControllers
, который применит для такой навигации только одну анимацию:
Поэтому Nivelir предоставляет только одно действие для изменения стека навигации — ScreenSetStackAction, который инициализируется массивом так называемых модификаторов стека — простых операций, типа pop, push и т.д.
Выполнение ScreenSetStackAction
состоит из поочередного вызова всех его модификаторов для получения итогового стека, который затем отправляется в метод setViewControllers
. А склеивание двух таких действий является простым суммированием модификаторов.
Как выглядят модификаторы стека?
Модификаторы стека соответствуют протоколу ScreenStackModifier
:
protocol ScreenStackModifier {
func perform(
stack: [UIViewController],
navigator: ScreenNavigator
) throws -> [UIViewController]
}
Метод perform(stack:navigator:)
принимает текущий стек в параметре stack
и возвращает его в изменённом виде. На примере модификатора ScreenStackPushModifier
его реализация выглядит так:
func perform(
in stack: [UIViewController],
navigator: ScreenNavigator
) -> [UIViewController] {
stack + [screen.build(navigator: navigator)]
}
Из коробки доступны 4 реализации модификаторов, их достаточно для любой навигации со стеком:
ScreenStackPushModifier
: добавляет новый топовый экран в стек.ScreenStackClearModifier
: удаляет все экраны из стека.ScreenStackReplaceModifier
: заменяет топовый экран стека.ScreenStackPopModifier
: удаляет топовые экраны из стека.
Модификатор ScreenStackPopModifier
имеет собственный предикат, который позволяет удалять топовые экраны стека с определенным условием. Можно реализовать свои условия или использовать стандартные предикаты: previous
, root
и т.д.
Какие ещё есть действия?
Все действия навигации условно разделены по типу контейнера, в котором они выполняются:
- Действия окна: применяются к контейнеру
UIWindow
. - Модальные действия: применяются к контейнеру
UIViewController
. - Действия табов: применяются к контейнеру
UITabBarController
. - Действия стека: применяются к контейнеру
UINavigationController
. - Общие действия: применимы к любому контейнеру.
Следует помнить, что модальные действия можно выполнять и с контейнерами UITabBarController
и UINavigationController
, так как они являются наследниками UIViewController
.
Кроме базовых действий есть ещё слой Addons с удобными расширениями: показ алертов, выбор фото, открытие URL и т.д. А если и этого мало, всегда можно добавить свои специфичные действия.
Роуты
Любая навигация состоит минимум из двух действий:
- Получение контейнера: заранее известного или найденного в иерархии.
- Выполнение перехода в этом контейнере: present, push и т.д.
Поэтому действия навигации не применяются самостоятельно, а входят в состав отдельной сущности для описания навигации — роутов. Они представлены в виде структуры ScreenRoute
, которая реализует протокол ScreenThenable
:
protocol ScreenThenable {
associatedtype Root: ScreenContainer
associatedtype Current: ScreenContainer
var actions: [AnyScreenAction] { get }
func then(
_ action: Action
) -> Self where Action.Container == Current
func then(
_ other: Route
) -> Self where Route.Root == Current
}
В базовом виде роут можно модифицировать, добавляя действия и другие роуты через соответствующие методы then(:)
. Все они ограничены типом Current
, который является типом текущего контейнера.
Набор всех действий роута доступен в свойстве actions
, и он завязан на тип корневого контейнера, с которого начинается вся навигация, описываемая этим роутом. Этот корневой контейнер соответствует ассоциированному типу Root
.
Как лучше добавлять действия в роут?
На самом деле действия навигации необязательно добавлять в роут в явном виде, лучше использовать специальные методы из расширений протокола ScreenThenable
. Эти методы реализованы для каждого действия и на примере закрытия модального экрана выглядят так:
extension ScreenThenable where Current: UIViewController {
func dismiss(animated: Bool = true) -> Self {
then(ScreenDismissAction(animated: animated))
}
}
Метод dismiss(animated:)
создает экземпляр действия и добавляет его в роут вызовом протокольного метода then(:)
. Так реализованы простые расширения, а более сложные могут использовать методы других действий или даже комбинировать их.
Эти расширения полезны для удобства описания роутов. Не нужно помнить тип действия, достаточно набрать точку в объявлении, и Xcode предложит список всех доступных методов, которые применимы с текущим типом контейнера. К тому же это здорово сокращает код самих объявлений и повышает их читабельность.
Как объявить роут?
Описание навигации всегда начинается с корневого роута, у которого равны типы контейнеров Root
и Current
. Nivelir предоставляет готовые алиасы для таких роутов под тип их контейнера:
typealias ScreenModalRoute = ScreenRootRoute
typealias ScreenStackRoute = ScreenRootRoute
typealias ScreenTabsRoute = ScreenRootRoute
typealias ScreenWindowRoute = ScreenRootRoute
Чаще всего из этих алиасов используется ScreenWindowRoute
, с его экземпляра начинается описание основного роута, например:
let route = ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
В этом роуте два действия: поиск топового стека и пуш экрана чата. Первое действие получит экземпляр окна в качестве контейнера, а второе использует то, что найдётся.
Остальные алиасы используются в основном для композиции, например этот же роут можно поделить на два:
let pushRoute = ScreenStackRoute()
.push(ChatScreen(chatID: 123))
let route = ScreenWindowRoute()
.top(.stack)
.then(pushRoute)
В этом примере описана та же навигация, но с переиспользованием роута pushRoute
, который начинается с экземпляра ScreenStackRoute
, так как пушить экран можно только в стек.
Каким будет тип роута?
Некоторые действия могут менять тип текущего контейнера Current
у роута, неизменным остается только корневой контейнер Root
. Например, вызов top(.stack)
сделает текущим контейнером UINavigationController
, и последующие действия будут выполняться на нём, пока какое-нибудь снова не поменяет контейнер.
Это позволяет «чейнить» переиспользованный роут с текущим контейнером, но может доставить хлопот с определением его типа в методах, например:
func chatRoute(
chatID: Int
) -> ScreenRoute {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
}
Такую запись нельзя назвать компактной, к тому же тип может поменяться после изменения самого роута.
Проектам под iOS с версии 13 и выше повезло, для них доступны Opaque Types, можно использовать протокол ScreenThenable
с ключевым словом some
и забыть про конкретный тип:
func chatRoute(chatID: Int) -> some ScreenThenable {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
}
Менее удобное, но универсальное решение — сделать роут корневым, добавив вызов метода resolve()
в конце:
func chatRoute(chatID: Int) -> ScreenWindowRoute {
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
.resolve()
}
В этом случае роут будет иметь тот же тип, с которым он и создавался, но компилятор уже не позволит запушить второй экран в стек после вызова resolve()
, так как текущим контейнером будет UIWindow
.
Как выполнить роут?
Чтобы выполнить навигацию, достаточно скормить роут навигатору:
ScreenWindowRoute()
.top(.stack)
.push(ChatScreen(chatID: 123))
navigator.navigate(to: route)
Метод navigate(to:)
принимает только роуты, у которых контейнер Root
равен UIWindow
, то есть ScreenWindowRoute
. Навигатор выполнит действия роута последовательно, передав им на вход свой экземпляр окна.
Если в нашем примере топовый стек не найдётся в иерархии, то навигатор залогирует ошибку, а чтобы обработать результат самостоятельно, можно определить замыкание в параметре completion
. Но если в случае ошибки необходимо выполнить другие действия навигации, то такую логику можно легко описать в самом роуте.
Как обработать ошибки в роуте?
Допустим, мы хотим откатиться на модальное отображение чата в случае, если топовый контейнер не является стеком. Тогда можно воспользоваться методом fallback(to:)
и передать в него другой роут:
let screen = ChatScreen(chatID: 123)
let presentRoute = ScreenWindowRoute()
.top(.container)
.present(screen)
let pushRoute = ScreenWindowRoute()
.top(.stack)
.push(screen)
.fallback(to: presentRoute)
Роут presentRoute
ищет топовый контейнер в иерархии независимо от его типа и отображает на нём экран модально. Метод fallback(to:)
запустит его, если роут pushRoute
не найдёт топовый стек.
А если контейнер уже известен?
Для локальной навигации не обязательно искать контейнер, он нам уже известен — это наш контроллер. В этом случае можно воспользоваться методом from(:)
. Допустим, у нас есть свойство container
, в котором объявлен обычный контроллер, тогда пример его использования выглядит так:
let route = ScreenWindowRoute()
.from(container)
.stack
.push(ChatScreen(chatID: 123))
navigator.navigate(to: route)
Так как наш контейнер не является стеком, нам сначала нужно получить его свойством stack
. Компилятор не позволит запушить экран чата сразу в модальный контейнер.
При желании можно объединить описание роута с его выполнением. Для кейсов локальной навигации это может быть удобнее, если нет необходимости переиспользовать роуты:
navigator.navigate(from: container) { route in
route.stack.push(ChatScreen(chatID: 123))
}
В этом примере мы схитрили и использовали перегрузку метода navigate(from:)
, в который можно передать контейнер сразу, и он станет корневым для роута в замыкании.
А если экран уже есть в иерархии?
В случае диплинков часто требуется показать экран, который уже есть в иерархии. Например, пользователь открыл диплинк чата, и если он уже отображается на табе чатов, то нужно показать его, иначе запушить в топовый стек. Для таких сценариев полезен метод makeVisible()
:
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)
Метод last(:)
добавит в роут действие для поиска последнего контейнера в иерархии. Поиск будет производится с учётом идентификатора чата, так как мы указали его отличительным признаком для ChatScreen
.
Если контейнер есть в иерархии, метод makeVisible()
сделает всё, чтобы он стал топовым: закроет все модальные экраны на нём, рекурсивно переключит табы, сбросит стек до нужного контейнера. В случае отсутствия экрана с искомым чатом в иерархии роут откатится на пуш в топовый стек, а если и его не удалось найти, то применится роут модального отображения.
Тут есть одна проблема: в случае пуша в стек экран чата можно использовать как есть, но для модального отображения его лучше обернуть в собственный стек с кнопкой закрытия в навбаре. Возможность такой кастомизации предоставляет отдельный слой — декораторы.
Декораторы
Декораторы — это объекты, которые оборачивают сборщик модуля и изменяют создаваемый контейнер. Им соответствует протокол ScreenDecorator
:
protocol ScreenDecorator {
associatedtype Container: ScreenContainer
associatedtype Output: ScreenPayloadedContainer
var payload: Any? { get }
func build(
screen: Wrapped,
navigator: ScreenNavigator
) -> Output where Wrapped.Container == Container
}
Все декораторы ассоциированы с контейнером, и метод build(screen:navigator:)
принимает сборщик модуля с тем же типом контейнера. Но после сборки модуля метод может вернуть другой контейнер, например, сборщик создает UIViewController
, а декоратор оборачивает его в UINavigationController
.
Также декораторы могут иметь данные, которые должны быть в памяти до тех пор, пока жив контейнер. Например, кастомный аниматор модального отображения. Такие данные должны быть определены в свойстве payload
, который под капотом ассоциируется с контейнером.
Как декорировать?
Реализация по умолчанию протокола Screen
содержит метод decorated(by:)
, который принимает экземпляр декоратора и возвращает сборщик экрана, обернув его в этот декоратор. Такая реализация позволяет навешивать несколько декораторов подряд на один и тот же сборщик.
Метод decorated(by:)
полезен только для реализации кастомных декораторов, так как все стандартные добавляют удобные методы в расширение протокола Screen
. С ними наш пример роута для модального отображения чата будет выглядеть так:
let presentRoute = ScreenWindowRoute()
.top(.container)
.present(
screen
.withLeftBarButton(closeBarButton)
.withStackContainer()
.withModalPresentationStyle(.fullScreen)
)
В случае выполнения этого роута контейнер чата получит левую кнопку в навбаре, затем будет обернут в контейнер стека, который в свою очередь будет иметь полноэкранный стиль отображения. Порядок декораторов важен, так как они применяются для разных контейнеров: левая кнопка добавляется для ChatViewController
, а модальный стиль отображения меняется для UINavigationController
.
Подводя итог
Nivelir позволяет легко описывать любую навигацию с известными ему контейнерами. Что-то специфичное очень просто реализуется на любом уровне: от действий навигации до логирования.
Кроме базовых возможностей, рассмотренных в статье, есть ещё много «сахара» и удобных хелперов, которые мы продолжаем выносить из нашего проекта и работаем над документацией.
Как стабильность?
Со стабильностью нет никаких проблем, Nivelir уже прошел все проверки: production наших приложений, тонну UI-тестов и еженедельное регрессионное тестирование зоркими QA.
Как поиграть?
В репозитории есть простой демо-проект, который можно использовать как песочницу, для этого достаточно выполнить команды в терминале:
git clone https://github.com/hhru/Nivelir.git
cd Nivelir/Example
pod install
open NivelirExample.xcworkspace
В этом проекте реализована локальная навигация и использование некоторых дополнений. Скоро появится пример реализации диплинков с авторизацией.
Что ещё?
Nivelir можно использовать и для локальной навигации экранов, и в координаторах, и для реализации более совершенных решений для глобального роутинга. Про нашу архитектуру межмодульной навигации и работу с диплинками мы готовим отдельный материал. Будет интересно, stay tuned!
На этом всё
Буду рад обратной связи в комментариях. Пока!