Делаем Jetpack Navigation 3 удобнее

от автора

Привет, Хабр! Меня зовут Артем и я автор и ведущий 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *