Привет, Хабр! Меня зовут Артем и я автор и ведущий YouTube и Telegram каналов Android Insights.
Что такое Jetpack Navigation 3
Jetpack Navigation 3 — это новая версия библиотеки навигации от Google, которая кардинально отличается от предыдущих версий. Основная идея Nav3 проста: у вас есть NavBackStack — обычный изменяемый список, где каждый элемент представляет экран в вашем приложении. Вы добавляете и удаляете элементы из этого списка, и UI автоматически обновляется. Каждый экран представлен как NavKey — обычный Kotlin-класс.
Это даёт полный контроль над навигацией, но требует написания довольно много шаблонного кода для типовых операций.
Почему работать напрямую с NavBackStack неудобно
Давайте посмотрим, как выглядит код при прямой работе с NavBackStack:
@Composable fun MyApp() { val backStack = rememberNavBackStack(Screen.Home) // Добавить экран backStack.add(Screen.Details("123")) // Вернуться назад backStack.removeLastOrNull() // Заменить текущий экран backStack.set(backStack.lastIndex, Screen.Success) }
Проблемы начинаются, когда вам нужно вызвать навигацию из ViewModel. Придётся либо передавать NavBackStack в ViewModel (что в моем понимании нарушает принципы архитектуры, так как я считаю, что ViewModel не должна знать о Compose-специфичных вещах), либо создавать промежуточные callback’и для каждого действия навигации.
Кроме того, при работе со стеком напрямую легко забыть обработать граничные случаи.
Как Nav3 Router упрощает работу
Nav3 Router — это тонкая обёртка над Navigation 3, которая предоставляет привычный API для навигации. Вместо того чтобы думать об индексах и операциях со списком, вы просто говорите «перейди на экран X» или «вернись назад».
Важный момент: Nav3 Router не создаёт свой собственный стек. Он работает с тем же NavBackStack, который предоставляет Navigation 3, просто делает работу с ним удобнее. Когда вы вызываете router.push(Screen.Details), библиотека транслирует это в соответствующую операцию с оригинальным стеком.
Основные преимущества:
-
Можно использовать из ViewModel
-
Команды навигации буферизируются, если UI временно недоступен (например, при повороте экрана)
-
Все операции со стеком происходят атомарно
-
Понятный API
-
Гибкость в модификации и добавлении собственного поведения
Подключение
Nav3 Router доступен в Maven Central. Добавьте зависимость в ваш build.gradle.kts:
// Для shared модуля в KMP проекте kotlin { sourceSets { commonMain.dependencies { implementation("io.github.arttttt.nav3router:nav3router:1.0.0") } } } // Для Android-only проекта dependencies { implementation("io.github.arttttt.nav3router:nav3router:1.0.0") }
Исходный код библиотеки доступен на GitHub: github.com/arttttt/Nav3Router
Как устроен Nav3 Router
Библиотека состоит из трёх основных частей, каждая решает свою задачу:
Router — интерфейс для разработчика
Router предоставляет методы вроде push(), pop(), replace(). Когда вы вызываете эти методы, Router создаёт соответствующие команды и отправляет их дальше по цепочке. Сам Router не знает ничего о том, как именно будет выполнена навигация — это позволяет использовать его откуда угодно.
CommandQueue — буфер между командами и их выполнением
CommandQueue решает проблему таймингов. Представьте: пользователь нажал кнопку в момент поворота экрана. UI пересоздаётся, навигатор временно недоступен. CommandQueue сохранит команду и выполнит её, как только навигатор снова будет готов. Без этого команда просто потерялась бы.
// Упрощённая логика работы очереди class CommandQueue { private var navigator: Navigator? = null private val pending = mutableListOf<Command>() fun executeCommand(command: Command) { if (navigator != null) { navigator.apply(command) // Есть навигатор - выполняем сразу } else { pending.add(command) // Нет - сохраняем на потом } } }
Navigator — тот, кто работает со стеком
Navigator берёт команды и применяет их к NavBackStack. Важная деталь: он сначала создаёт копию текущего стека, применяет к ней все команды, и только потом атомарно заменяет оригинальный стек на модифицированную копию. Это гарантирует, что UI никогда не увидит промежуточные состояния стека.
// Упрощённая логика Navigator fun applyCommands(commands: Array) { val stackCopy = backStack.toMutableList() // Работаем с копией for (command in commands) { when (command) { is Push -> stackCopy.add(command.screen) is Pop -> stackCopy.removeLastOrNull() // ... другие команды } } backStack.swap(stackCopy) // Атомарно применяем изменения }
Начинаем использовать Nav3 Router
Самый простой способ — даже не создавать Router вручную. Nav3Host сделает это за вас:
@Composable fun App() { val backStack = rememberNavBackStack(Screen.Home) // Nav3Host создаст Router автоматически Nav3Host(backStack = backStack) { backStack, onBack, router -> NavDisplay( backStack = backStack, onBack = onBack, entryProvider = entryProvider { entry { HomeScreen( onOpenDetails = { router.push(Screen.Details) // Используем router } ) } entry { DetailsScreen( onBack = { router.pop() } ) } } ) } }
Для более сложных приложений имеет смысл создавать Router через DI и передавать его в ViewModel:
Определяем экраны
@Serializable sealed interface Screen : NavKey { @Serializable data object Home : Screen @Serializable data class Product(val id: String) : Screen @Serializable data object Cart : Screen }
Передаем router в Nav3Host
@Composable fun App() { val backStack = rememberNavBackStack(Screen.Home) val router: Router = getSomehowUsingDI() // Передаем Router в Nav3Host Nav3Host( backStack = backStack, router = router, ) { backStack, onBack, _ -> NavDisplay( backStack = backStack, onBack = onBack, entryProvider = entryProvider { entry<Screen.Home> { HomeScreen() } entry<Screen.Details> { DetailsScreen() } } ) } }
ViewModel получает Router через конструктор
class ProductViewModel( private val router: Router, private val cartRepository: CartRepository ) : ViewModel() { fun addToCart(productId: String) { viewModelScope.launch { cartRepository.add(productId) router.push(Screen.Cart) // Навигация из ViewModel } } }
В UI просто используем ViewModel
@Composable fun ProductScreen(viewModel: ProductViewModel = koinViewModel()) { Button(onClick = { viewModel.addToCart(productId) }) { Text("В корзину") } }
Примеры типовых сценариев
Простая навигация вперёд-назад
// Переход на новый экран router.push(Screen.Details(productId)) // Возврат назад router.pop() // Переход с заменой текущего экрана (назад вернуться нельзя) router.replaceCurrent(Screen.Success)
Работа с цепочками экранов
// Открыть сразу несколько экранов router.push( Screen.Category("electronics"), Screen.Product("laptop-123"), Screen.Reviews("laptop-123") ) // Вернуться к конкретному экрану // Удалит все экраны выше Product из стека router.popTo(Screen.Product("laptop-123"))
Сценарий оформления заказа
@Composable fun CheckoutScreen(router: Router) { Button( onClick = { // После оформления заказа нужно: // 1. Показать экран подтверждения // 2. Не дать вернуться обратно в корзину router.replaceStack( Screen.Home, Screen.OrderSuccess(orderId) ) // Теперь в стеке только Home и OrderSuccess } ) { Text("Оформить заказ") } }
Выход из вложенной навигации
// Пользователь глубоко в настройках: // Home -> Settings -> Account -> Privacy -> DataManagement // Кнопка "Готово" должна вернуть на главную Button( onClick = { // Оставит только root (Home) router.clearStack() } ) { Text("Готово") } // Или если нужно закрыть приложение из любого места Button( onClick = { // Оставит только текущий экран и вызовет системный back router.dropStack() } ) { Text("Выйти") }
Бонус: SceneStrategy и модальные окна
До сих пор мы говорили только о простой навигации между экранами. Но что если вам нужно показать диалог или bottom sheet? Здесь на помощь приходит концепция SceneStrategy из Navigation 3.
Что такое SceneStrategy
SceneStrategy — это механизм, который определяет, как именно будут отображаться экраны из вашего стека. По умолчанию Navigation 3 использует SinglePaneSceneStrategy, который просто показывает последний экран из стека. Но вы можете создавать свои стратегии для более сложных сценариев.
Представьте SceneStrategy как режиссёра, который смотрит на ваш стек экранов и решает: «Так, эти три экрана показываем как обычно, а вот этот последний — как модальное окно поверх предыдущих». Это позволяет одним и тем же стеком представлять разные UI-паттерны.
Создаём стратегию для ModalBottomSheet
Давайте создадим стратегию, которая будет показывать определённые экраны как bottom sheet. Сначала определим, как мы будем помечать такие экраны:
@Serializable sealed interface Screen : NavKey { @Serializable data object Home : Screen @Serializable data class Product(val id: String) : Screen // Этот экран будем показывать как bottom sheet @Serializable data object Filters : Screen }
Теперь создадим саму стратегию. Она будет проверять метаданные последнего экрана и, если находит специальный маркер, показывать его как bottom sheet:
class BottomSheetScene<T : Any>( override val key: T, override val previousEntries: List<NavEntry<T>>, override val overlaidEntries: List<NavEntry<T>>, private val entry: NavEntry<T>, private val onBack: (count: Int) -> Unit, ) : OverlayScene<T> { override val entries: List<NavEntry<T>> = listOf(entry) override val content: @Composable (() -> Unit) = { ModalBottomSheet( onDismissRequest = { onBack(1) }, ) { entry.Content() } } } class BottomSheetSceneStrategy<T : Any> : SceneStrategy<T> { companion object { // Ключ для метаданных, по которому определяем bottom sheet private const val BOTTOM_SHEET_KEY = "bottomsheet" // Вспомогательная функция для создания метаданных fun bottomSheet(): Map { return mapOf(BOTTOM_SHEET_KEY to true) } } @Composable override fun calculateScene( entries: List<NavEntry<T>>, onBack: (Int) -> Unit ): Scene? { val lastEntry = entries.lastOrNull() ?: return null // Проверяем, есть ли у последнего экрана маркер bottom sheet val isBottomSheet = lastEntry.metadata[BOTTOM_SHEET_KEY] as? Boolean if (isBottomSheet == true) { // Возвращаем специальную Scene для bottom sheet return BottomSheetScene( entry = lastEntry, previousEntries = entries.dropLast(1), onBack = onBack ) } // Это не bottom sheet, пусть другая стратегия обработает return null } }
Комбинируем несколько стратегий
В реальном приложении вам может понадобиться и bottom sheet, и диалоги, и обычные экраны. Для этого можно создать стратегию-делегат, которая будет выбирать нужную стратегию для каждого экрана:
class DelegatedScreenStrategy<T : Any>( private val strategyMap: Map<String, SceneStrategy<T>>, private val fallbackStrategy: SceneStrategy<T> ) : SceneStrategy<T> { @Composable override fun calculateScene( entries: List<NavEntry<T>>, onBack: (Int) -> Unit ): Scene<T>? { val lastEntry = entries.lastOrNull() ?: return null // Проверяем все ключи в метаданных for (key in lastEntry.metadata.keys) { val strategy = strategyMap[key] if (strategy != null) { // Нашли подходящую стратегию return strategy.calculateScene(entries, onBack) } } // Используем стратегию по умолчанию return fallbackStrategy.calculateScene(entries, onBack) } }
Используем в приложении
Теперь соберём всё вместе. Вот как будет выглядеть использование bottom sheet в реальном приложении:
@Composable fun ShoppingApp() { val backStack = rememberNavBackStack(Screen.Home) val router = rememberRouter<Screen>() Nav3Host( backStack = backStack, router = router ) { backStack, onBack, router -> NavDisplay( backStack = backStack, onBack = onBack, // Используем нашу комбинированную стратегию sceneStrategy = DelegatedScreenStrategy( strategyMap = mapOf( "bottomsheet" to BottomSheetSceneStrategy(), // Navigation 3 уже имеет эту стратегию "dialog" to DialogSceneStrategy() ), // Обычные экраны fallbackStrategy = SinglePaneSceneStrategy() ), entryProvider = entryProvider { entry<Screen.Home> { HomeScreen( onOpenFilters = { // Открываем фильтры как bottom sheet router.push(Screen.Filters) } ) } entry<Screen.Product> { screen -> ProductScreen(productId = screen.id) } // Указываем, что Filters должен быть bottom sheet entry<Screen.Filters>( metadata = BottomSheetSceneStrategy.bottomSheet() ) { FiltersContent( onApply = { filters -> // Применяем фильтры и закрываем bottom sheet applyFilters(filters) router.pop() } ) } } ) } }
Что здесь происходит? Когда вы вызываете router.push(Screen.Filters), экран добавляется в стек как обычно. Но благодаря метаданным и нашей стратегии, UI понимает, что этот экран нужно показать как bottom sheet поверх предыдущего экрана, а не заменить его полностью.
При вызове router.pop() bottom sheet закроется, и вы вернётесь к предыдущему экрану. С точки зрения Router’а это обычная навигация назад, но визуально это выглядит как закрытие модального окна.
Преимущества такого подхода
Использование SceneStrategy даёт несколько важных преимуществ. Во-первых, ваша навигационная логика остаётся простой — вы всё так же используете push и pop, не задумываясь о том, как именно будет показан экран. Во-вторых, состояние навигации остаётся консистентным — bottom sheet это просто ещё один экран в стеке, который правильно сохраняется при повороте экрана или процесс-килле. И наконец, это даёт большую гибкость — вы можете легко менять способ отображения экрана, просто изменив его метаданные, не трогая навигационную логику.
Такой подход особенно полезен, когда один и тот же экран может быть показан по-разному в зависимости от контекста. Например, экран входа может быть обычным экраном при первом запуске приложения и модальным диалогом при попытке выполнить действие, требующее авторизации.
Почему стоит использовать Nav3 Router
Nav3 Router не пытается заменить Navigation 3 или добавить новые фичи. Его задача — сделать работу с навигацией удобной и предсказуемой. Вы получаете простой API, который можно использовать из любого слоя приложения, автоматическую обработку проблем с таймингами, и возможность легко тестировать навигационную логику.
При этом под капотом всё равно работает обычный Navigation 3 со всеми его возможностями: сохранением состояния, поддержкой анимаций и правильной обработкой системной кнопки «Назад».
Если вы уже используете Navigation 3 или планируете мигрировать на неё, Nav3 Router поможет сделать этот опыт приятнее, не добавляя лишней сложности в проект.
Ссылки
-
GitHub репозиторий: github.com/arttttt/Nav3Router
-
Примеры использования: смотрите папку
sampleв репозитории
ссылка на оригинал статьи https://habr.com/ru/articles/946878/
Добавить комментарий