В данной статье опишу подход google, как организовать навигацию в android проекте на чистом compose UI.
Добавление зависимостей Gradle
Откройте файл app/build.gradle.kts и добавьте в секцию dependencies зависимость navigation-compose.
dependencies { implementation("androidx.navigation:navigation-compose:2.4.0-beta02") }
Установка NavController
NavController является корневым элементом навигации compose, который отвечает за backstack composable функций её перемещение вперед, назад управление состоянием.
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { App() } } }
@Composable fun App() { AppTheme { val appState = rememberAppState() Scaffold( bottomBar = { if (appState.shouldShowBottomBar) { BottomBar( tabs = appState.bottomBarTabs, currentRoute = appState.currentRoute!!, navigateToRoute = appState::navigateToBottomBarRoute ) } }, scaffoldState = appState.scaffoldState ) { NavHost( navController = appState.navController, startDestination = MainDestinations.HOME_ROUTE ) { navGraph() } } } }
Добавим подписчика для отслеживания событий изменения состояния навигации rememberAppState()
@Composable fun rememberAppState( scaffoldState: ScaffoldState = rememberScaffoldState(), navController: NavHostController = rememberNavController() ) = remember(scaffoldState, navController) { AppState(scaffoldState, navController) }
Определим Graph навигации, укажем название графа route и стартовый экран startDestination (тип задаваемых значений String)
fun NavGraphBuilder.navGraph() { navigation( route = MainDestinations.HOME_ROUTE, startDestination = HomeSections.CATALOG.route ) { addHomeGraph() } }
Граф навигации
Определим какие будут экраны в графе
fun NavGraphBuilder.addHomeGraph( modifier: Modifier = Modifier ) { composable(HomeSections.CATALOG.route) { CatalogScreen() } composable(HomeSections.PROFILE.route) { ProfileScreen() } composable(HomeSections.SEARCH.route) { SearchScreen() } }
Добавим объект MainDestinations, который будет содержать название экранов, по которым будет осуществляться навигация.
object MainDestinations { const val HOME_ROUTE = "home" const val GAME_CARD_DETAIL_ROUTE = "cardRoute" const val GAME_CARD = "gameCard" const val SUB_CATALOG_ROUTE = "subCatalog" const val CATALOG_GAME = "catalogGame" }
Добавим enum class содержащий список вкладок в bottomNavigation
enum class HomeSections( @StringRes val title: Int, val icon: ImageVector, val route: String ) { CATALOG(R.string.home_catalog, Icons.Outlined.Home, "$HOME_ROUTE/catalog"), PROFILE(R.string.home_profile, Icons.Outlined.AccountCircle, "$HOME_ROUTE/profile"), SEARCH(R.string.home_search, Icons.Outlined.Search, "$HOME_ROUTE/search") }
Добавим класс работающий с состоянием навигации AppState.kt
@Stable class AppState( val scaffoldState: ScaffoldState, val navController: NavHostController ) { // ---------------------------------------------------------- // Источник состояния BottomBar // ---------------------------------------------------------- val bottomBarTabs = HomeSections.values() private val bottomBarRoutes = bottomBarTabs.map { it.route } // Атрибут отображения навигационного меню bottomBar val shouldShowBottomBar: Boolean @Composable get() = navController .currentBackStackEntryAsState().value?.destination?.route in bottomBarRoutes // ---------------------------------------------------------- // Источник состояния навигации // ---------------------------------------------------------- val currentRoute: String? get() = navController.currentDestination?.route fun upPress() { navController.navigateUp() } // Клик по навигационному меню, вкладке. fun navigateToBottomBarRoute(route: String) { if (route != currentRoute) { navController.navigate(route) { launchSingleTop = true restoreState = true //Возвращаем выбранный экран, //иначе если backstack не пустой то показываем ранее открытое состяние popUpTo(findStartDestination(navController.graph).id) { saveState = true } } } } } private fun NavBackStackEntry.lifecycleIsResumed() = this.lifecycle.currentState == Lifecycle.State.RESUMED private val NavGraph.startDestination: NavDestination? get() = findNode(startDestinationId) private tailrec fun findStartDestination(graph: NavDestination): NavDestination { return if (graph is NavGraph) findStartDestination(graph.startDestination!!) else graph }
Промежуточный результат описанный выше. Добавили graph, навигационное меню, описали экраны которые будут участвовать в навигации. Далее опишу два способа как передать параметры из одного экрана в другой.
Передача аргументов
Пример 1. Сериализуем передаваемый объект в String json
добавим в AppState.kt функцию для перехода на карточку с игрой. Передаваемый объект в моём случае это был data class GameCard должен быть помечен аннотацией Serializable.
fun navigateToGameCard(game: GameCard, from: NavBackStackEntry) { //Проверяем ЖЦ навигации, чтобы избавиться от повторяющихся событий. Ложных нажатий. if (from.lifecycleIsResumed()) { navigateModel( route = MainDestinations.GAME_CARD_DETAIL_ROUTE, model = game ) } } inline fun <reified T> navigateModel(route: String, model: T) { val json = Json.encodeToString(model) navController.navigate("$route/$json") }
заметим, что метод navController.navigate(..) принимает тип String на вход т.е. route содержащий в себе путь, что открыть и аргумент после /
Теперь расшифруем данные на принимающей стороне и выполним переход на карточку с игрой
Модифицируем addHomeGraph, добавив composable открытия карточки.
fun NavGraphBuilder.addHomeGraph( upPress: () -> Unit ) { composable( route = "${MainDestinations.GAME_CARD_DETAIL_ROUTE}/{${MainDestinations.GAME_CARD}}", arguments = listOf(navArgument(MainDestinations.GAME_CARD) { type = NavType.StringType }) ) { backStackEntry -> val arguments = requireNotNull(backStackEntry.arguments) arguments.getString(MainDestinations.GAME_CARD)?.let { cardDataString -> val card = Json.decodeFromString<GameCard>(cardDataString) CardDialog(card, upPress) } } }
Указываем route, который принимает на вход название пути MainDestinations.GAME_CARD_DETAIL_ROUTE и объект MainDestinations.GAME_CARD string который открываем. Следующий параметр arguments который содержит в себе список аргументов примитивных типов.
Пример 2. Передача параметра
fun navigateToGameCard(game: Int, from: NavBackStackEntry) { //Проверяем ЖЦ навигации, чтобы избавиться от повторяющихся событий. Ложных нажатий. if (from.lifecycleIsResumed()) { navController.navigate("${MainDestinations.GAME_CARD_DETAIL_ROUTE}/$game") } }
composable( route = "${MainDestinations.GAME_CARD_DETAIL_ROUTE}/{${MainDestinations.GAME_CARD}}", arguments = listOf(navArgument(MainDestinations.GAME_CARD) { type = NavType.IntType }) ) { backStackEntry -> val arguments = requireNotNull(backStackEntry.arguments) val gameCardId = arguments.getInt(MainDestinations.GAME_CARD, 0) if(gameCardId != 0) CardDialog(gameCardId, upPress, {}, {}) }
Отличительной особенностью является, передача ID карточки с последующим её запросом к БД для извлечения всех необходимых данных.
Примечание: единственным разочарованием было, что теперь при навигации необходимо задавать маршрут перехода route в строковом формате, тогда как обычном jetpack navigation задавались id для фрагментов в создаваемом графе и системой создавался список маршрутов в ресурсах. Чтобы меньше создавать логических ошибок, рекомендую названия route выносить в отдельный файл, который будет за это отвечать.
Репозиторий с рассмотренной навигацией можно найти на github.
