В данной статье опишу подход 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.

Комментарии (0)