Jetpack Navigation 3. Розберемо чи вдалося Google виправити помилки Navigation 2
Вітаю! Я — Роман, Android-інженер. У цій статті розповім про те, як Google провели роботу над помилками та зробили Navigation 3. Також розглянемо, як користуватися новою бібліотекою на практиці.
❗️Navigation 3 знаходиться на етапі альфа-версії (на момент написання статті
alpha07крайня версія). Тому дещо може змінитися у API.
Я постараюся слідкувати за новими версіями та оновлювати статтю за потреби 😉
UPD.1У версіїalpha08добавили артефакти зNavigation3 Runtimeна такі платформи JVM (Android and Desktop), Native (Linux, iOS, watchOS, macOS, MinGW), and Web (JavaScript, WasmJS), тобто доступніNavKey,NavEntry,NavBackStackта ін.Navigation3 UIвсе ще не підтримується, тому NavDisplay та Scene ще недоступні.
UPD.2У версіїalpha09пофіксили баг попередньої версії пов’язаний зі зламаним життєвим циклом (lifecycle state застрягав на onCreate). З важливих нововведень хочу відмітити, що NavBackStack став класом та отримав дженерік, а також є Serializable, що дозволяє його зберігання.
Проблеми та недоліки Navigation 2
Першочергово Navigation 2 створювали для роботи з фрагментами та
- Тоді як
XML-навігація мала Safe Args для навігації, у Compose довгий час потрібно було приручати String Routes з аргументами. - Відсутність прямого контролю над backStack. Взаємодія можлива тільки через додатковий прошарок у вигляді NavController.
- Складнощі з імплементацією анімацій та діплінків.
Варто зазначити, що згодом Google покращили Routes та Deeplink за допомогою Type Safety, що дозволило використовувати Serializable objects.
@Serializable data class ProfileRoute(val id: String)
Робота над помилками у Navigation 3
✅ Прямий доступ до backStack
Тепер відповідальність за backStack повністю на ваших плечах. NavBackStack — це аліас до SnapshotStateList<T> з будь-яким типом на ваш вибір. Оскільки це простий список, а його елементи Serializable, ми можемо його зберігати у DataStore(SharedPrefs).
Список передається у NavDisplay для спостереженням за змінами та відповідною реакцією у лямбді entryProvider. У своєму прикладі я використав NavKey (спеціальний інтерфейс-маркер з бібліотеки), як тип для backStack.
val backStack = rememberNavBackStack<NavKey>(Destination.Dashboard)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = { key -> … }
)
✅ Type safe routes
У Navigation 3 Google продовжив використовувати підхід з Kotlin DSL Type Safety.
Я створив sealed interface Destination, який наслідує вищезгаданий NavKey, а всередині потрібні мені Routes.
@Serializable
sealed interface Destination : NavKey {
@Serializable
data object Dashboard : Destination
@Serializable
data class Profile(val id: String) : Destination
}❗️Всі ваші Routes класи чи об’єкти повинні бути позначені анотацією @Serializable для коректного зберігання.
✅ Покращений контроль над анімаціями
Бібліотека дозволяє нам налаштувати всі види анімацій навігації за допомогою Compose Animation API:
- transitionSpec (як з’являється новий та зникає старий екран);
- popTransitionSpec (як зникає новий та з’являється старий екран);
- predictivePopTransitionSpec (використовується замість popTransitionSpec, якщо є підтримка Predictive Back Gesture).
І це ще одна перевага Navigation 3. Проста і нативна підтримка Predictive Back Gesture.
Є два варіанти додавання анімацій:
Перший — через NavDisplay, тоді у всіх екранів буде однакова анімація.
NavDisplay(
…
transitionSpec = { slideInHorizontally() togetherWith slideOutHorizontally() },
popTransitionSpec = { scaleIn() togetherWith scaleOut() },
predictivePopTransitionSpec = { fadeIn() togetherWith fadeOut() },
)
Другий — окремо у кожен entry через метадані.
entry<Profile>(
metadata = NavDisplay.transitionSpec {
slideInHorizontally() togetherWith slideOutHorizontally()
} + NavDisplay.popTransitionSpec {
scaleIn() togetherWith scaleOut()
} + NavDisplay.predictivePopTransitionSpec {
fadeIn() togetherWith fadeOut()
}
)ℹ️ Ви можете використати обидва варіанти, але перевагу буде мати анімація, що записана у entry metadata.
❌ Поки що я не знайшов інформацію про підтримку діплінків у Navigation 3. Думаю, що до першої стабільної версії Google точно додасть таку можливість.
Нові можливості у Navigation 3
EntryProvider
Для того щоб визначити, якому ключу який екран відповідає, ми використовуємо entryProvider лямбду у NavDisplay. Фактично, це мапер між нашим ключем та Composable екраном.
Звичайний підхід з використанням when statement. Для цього потрібно створити NavEntry, який приймає ключ та Composable контент як обов’язкові параметри, а також опційні метадані (не переплутайте їх з аргументами, це зовсім інше і призначене для певних дій під час навігації, як-от анімації).
NavDisplay(
…,
entryProvider = { key ->
when (key) {
is Dashboard -> NavEntry(
key = key,
metadata = mapOf("key" to "value"),
) {
DashboardScreen()
}
is Profile -> NavEntry(key) { ProfileScreen(key.id) }
}
}
)
DSL підхід, що дозволяє гарніше прописати залежності. Під капотом це той самий NavEntry.
NavDisplay(
entryProvider = entryProvider {
entry<Dashboard> { DashboardScreen() }
entry<Profile> { key ->
ProfileScreen(key.id)
}
}
)
Google не забув про мультимодульні проєкти та додав можливість інжектити Entry.
typealias EntryProviderInstaller = EntryProviderBuilder<Any>.() -> Unit
// App module
class MainActivity {
@Inject
lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderInstaller>
NavDisplay(
...,
entryProvider = entryProvider {
entryProviderBuilders.forEach { builder -> this.builder() }
}
)
}
// Profile module
@Module
@InstallIn(ActivityRetainedComponent::class)
object ProfileModule {
@IntoSet
@Provides
fun provideEntryProviderInstaller() : EntryProviderInstaller = {
entry<Profile>{
ProfileScreen()
}
}
}
Scenes API
Google представили інструмент для розробки гнучкого та масштабованого інтерфейсу. Тепер можна з легкістю робити адаптивний інтерфейс під планшети, розкладушки та звичайні смартфони. Базовий приклад — це створення сцени, яка в залежності від розміру екрану та типу entry, може відображати один список продуктів на звичайному смартфоні, а на планшеті буде показуватися список ліворуч та обраний продукт праворуч.
Круто, правда? А зараз ще побачите, як легко це реалізується.
Спершу опишемо макет, як саме повинні відображатися два екрани поруч. Це TwoPaneScene, який наслідує Scene та перевизначає content. Я зробив, щоб два entry ділили екран порівну.
class TwoPaneScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
override val entries: List<NavEntry<T>>,
) : Scene<T> {
override val content: @Composable (() -> Unit) = {
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.weight(0.5f),
contentAlignment = Alignment.Center,
) {
entries.first().Content()
}
Box(
modifier = Modifier.weight(0.5f),
contentAlignment = Alignment.Center,
) {
entries.last().Content()
}
}
}
companion object {
internal const val TWO_PANE_KEY = "TwoPane"
fun twoPane() = mapOf(TWO_PANE_KEY to true)
}
}
Наступний крок — це визначення логіки, як і коли ділити екран. Для цього я створюю TwoPaneSceneStrategy та наслідую SceneStrategy. У методі calculateScene спочатку рахується, чи підходить нам розмір екрану. Далі беруться останні два entry і перевіряємо по метаданим, чи можна показувати їх разом. Якщо все збігається, то ми повертаємо раніше створений TwoPaneScene, інакше null.
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
@Composable
override fun calculateScene(
entries: List<NavEntry<T>>,
onBack: (Int) -> Unit,
): Scene<T>? {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND).not()) {
return null
}
val lastTwoEntries = entries.takeLast(2)
return if (lastTwoEntries.size == 2
&& lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
) {
val twoPaneKey = (lastTwoEntries.first().contentKey to lastTwoEntries.last().contentKey)
TwoPaneScene(
key = twoPaneKey,
previousEntries = entries.dropLast(1),
entries = lastTwoEntries,
)
} else {
null
}
}
}
Далі все просто, створюємо TwoPaneSceneStrategy, передаємо у NavDisplay та додаємо метадані до тих entry, які потрібно відобразити разом на великому екрані.
val twoPaneStrategy = remember { TwoPaneSceneStrategy<NavKey>() }
NavDisplay(
...,
sceneStrategy = twoPaneStrategy,
entryProvider = entryProvider {
entry<Dashboard>(
metadata = TwoPaneScene.twoPane()
) {
DashboardScreen()
}
entry<Profile>(
metadata = TwoPaneScene.twoPane()
) { key ->
ProfileScreen(key.id)
}
}
)
EntryDecorators
Напевно, те що сподобалося мені найбільше, NavEntry декоратори. Простими словами — це обгортка навколо entry. Серед готових декораторів є обов’язкові rememberSceneSetupNavEntryDecorator та rememberSavedStateNavEntryDecorator, які забезпечують збереження стану під час рекомпозицій.
Додатково можна додати rememberViewModelStoreNavEntryDecorator для прив’язки життєвого циклу ViewModel до entry. У такому випадку ViewModel очиститься, коли entry буде видалено з backStack.
NavDisplay( …, entryDecorators = listOf( rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), twoPaneSharedEntryDecorator, ), )
Також можна створювати власні декоратори за допомогою методу navEntryDecorator.
Наприклад, можна зробити обгортку для TwoPaneScene з SharedTransition.
val twoPaneSharedEntryDecorator = navEntryDecorator<NavKey> { entry ->
with(localNavSharedTransitionScope.current) {
if (entry.metadata.containsKey(TWO_PANE_KEY)) {
Box(
modifier = Modifier.sharedElement(
rememberSharedContentState(entry.contentKey),
animatedVisibilityScope = LocalNavAnimatedContentScope.current,
),
) {
entry.Content()
}
} else {
entry.Content()
}
}
}
Висновки
Як бачимо, Google справді провів хорошу роботу над помилками. Navigation 3 виглядає простіше і зручніше, а також з’явилося досить багато новинок. Проте залишаються й відкриті питання щодо підтримки діплінків та Compose Multiplatform. Сподіваюся, що у нових версіях все проясниться.
Якщо у вас залишились запитання — із задоволенням відповім у коментарях 😉

3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів