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