Каждый Android-разработчик знает, что навигация в Jetpack Compose — не самая лучшая его сторона. Для ее осуществления приходится передавать слишком много callbacks
и navControllers
. А что если нужно выполнить бизнес-логику перед отправкой аргументов? В этом случае код начинает усложняться.
Вопрос о способах реализации навигации активно обсуждается. Кроме того, появилась одна отличная библиотека. Рекомендую с ней познакомиться и заодно поблагодарить ее создателя Rafael Costa.
Но что если вы не хотите ни от кого зависеть или политика вашей компании не допускает использования сторонних библиотек? Навигация является одним из важнейших компонентов приложения. Вы не можете полагаться на других, даже если библиотека доступна в режиме 24/7. Необходимо создать надежно работающее решение, и в данной статье мы как раз этим займемся. Но сначала разберемся в существующей проблеме, чтобы в полном мере понимать суть предпринимаемых действий.
Описание проблемы
В настоящее время наиболее распространенная реализация навигации выглядит так:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
composable(route = "home") {
HomeScreen(
navigatToUsersScreen = { navController.navigate("users") },
navigatToMessagesScreen = { navController.navigate("messages") },
navigatToDetailsScreen = { navController.navigate("details") }
)
}
composable(route = "users") {
UsersScreen(
navigateBack = { navController.navigateUp() },
navigatToUserDetailsScreen = { navController.navigate("user_details") }
)
}
composable(route = "user_details") {
UserDetailsScreen(
navigateBack = { navController.navigateUp() }
)
}
composable(route = "messages") {
MessagesScreen(
navigateBack = { navController.navigateUp() }
)
}
composable(route = "details") {
DetailsScreen(
navigateBack = { navController.navigateUp() }
)
}
}
Первый вариант предполагает передачу лямбда-функций, а второй — navController
. И то, и другое решение имеет свои недостатки. Чрезмерная сложность экрана может быть сопряжена с большим количеством обратных вызовов, что прилично утяжеляет код. Возможно, ваша логика вокруг маршрутов запрограммирована не так жестко, но суть проблемы понятна.
Допустим, нужно выполнить определенную бизнес-логику, например что-то вычислить, при этом результат вычисления является аргументом для следующего экрана. Для этого мы должны вызвать ViewModel
, а не View
, посмотреть результат и затем инициировать обратный вызов. Как видно, получается слишком много двунаправленных переходов между Screen
и ViewModel
.
Попробуем найти оптимальное решение для этой проблемы.
Чистая навигация Jetpack Compose
Идея решения заключается в реализации пользовательского навигатора, который предоставляется каждому ViewModel
. Вызывая функции этого навигатора, мы перемещаемся по разным экранам. Все события навигации собираются в MainScreen
, в связи с чем отпадает необходимость передавать обратные вызовы и navController
другим экранам. Далее рассмотрим соответствующий код, и вы все поймете.
Сначала создаем специальный класс для маршрутов:
sealed class Destination(protected val route: String, vararg params: String) {
val fullRoute: String = if (params.isEmpty()) route else {
val builder = StringBuilder(route)
params.forEach { builder.append("/{${it}}") }
builder.toString()
}
sealed class NoArgumentsDestination(route: String) : Destination(route) {
operator fun invoke(): String = route
}
object HomeScreen : NoArgumentsDestination("home")
object UsersScreen : NoArgumentsDestination("users")
object MessagesScreen : NoArgumentsDestination("messages")
object DetailsScreen : NoArgumentsDestination("details")
object UserDetailsScreen : Destination("user_details", "firstName", "lastName") {
const val FIST_NAME_KEY = "firstName"
const val LAST_NAME_KEY = "lastName"
operator fun invoke(fistName: String, lastName: String): String = route.appendParams(
FIST_NAME_KEY to fistName,
LAST_NAME_KEY to lastName
)
}
}
internal fun String.appendParams(vararg params: Pair<String, Any?>): String {
val builder = StringBuilder(this)
params.forEach {
it.second?.toString()?.let { arg ->
builder.append("/$arg")
}
}
return builder.toString()
}
Destination
имеет конструктор с 2 аргументами. Первый из них представляет собой базовый маршрут, а второй — параметры для этого маршрута. У каждого Destination
есть route
и fullRoute
. route
, базовый маршрут без параметров, используется для создания fullRoute
с именами параметров или со значениями параметров.
Вызов Destination
возвращает его путь. Функция appendParams
добавляет параметры к маршруту и возвращает fullRoute
со значениями параметров.
Далее добавляем требуемые составные элементы навигации (англ. Composables):
@Composable
fun NavHost(
navController: NavHostController,
startDestination: Destination,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
NavHost(
navController = navController,
startDestination = startDestination.fullRoute,
modifier = modifier,
route = route,
builder = builder
)
}
fun NavGraphBuilder.composable(
destination: Destination,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit
) {
composable(
route = destination.fullRoute,
arguments = arguments,
deepLinks = deepLinks,
content = content
)
}
NavHost
аналогичен элементу из androidx.navigation.compose
с той лишь разницей, что аргумент startDestination
имеет тип Destination
.
Тоже самое и с composable
: вместо route: String
применяется destination: Destination
.
Реализуем этот пользовательский навигатор посредством следующего кода:
interface AppNavigator {
val navigationChannel: Channel<NavigationIntent>
suspend fun navigateBack(
route: String? = null,
inclusive: Boolean = false,
)
fun tryNavigateBack(
route: String? = null,
inclusive: Boolean = false,
)
suspend fun navigateTo(
route: String,
popUpToRoute: String? = null,
inclusive: Boolean = false,
isSingleTop: Boolean = false,
)
fun tryNavigateTo(
route: String,
popUpToRoute: String? = null,
inclusive: Boolean = false,
isSingleTop: Boolean = false,
)
}
sealed class NavigationIntent {
data class NavigateBack(
val route: String? = null,
val inclusive: Boolean = false,
) : NavigationIntent()
data class NavigateTo(
val route: String,
val popUpToRoute: String? = null,
val inclusive: Boolean = false,
val isSingleTop: Boolean = false,
) : NavigationIntent()
}
У AppNavigator
есть канал navigationChannel
, который поставляет сведения в MainScreen
и располагает 4 функциями для навигации. NavigationIntent
содержит все возможные намерения по навигации. Вы можете дополнить их список, например намерением перейти по ссылке на конкретный ресурс или что-нибудь в этом роде. Аргументы каждого NavigationIntent
необходимы для функций navController
.
Реализация AppNavigator
не представляет сложности: просто отправляем NavigationIntents
в navigationChannel
.
class AppNavigatorImpl @Inject constructor() : AppNavigator {
override val navigationChannel = Channel<NavigationIntent>(
capacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
override suspend fun navigateBack(route: String?, inclusive: Boolean) {
navigationChannel.send(
NavigationIntent.NavigateBack(
route = route,
inclusive = inclusive
)
)
}
override fun tryNavigateBack(route: String?, inclusive: Boolean) {
navigationChannel.trySend(
NavigationIntent.NavigateBack(
route = route,
inclusive = inclusive
)
)
}
override suspend fun navigateTo(
route: String,
popUpToRoute: String?,
inclusive: Boolean,
isSingleTop: Boolean
) {
navigationChannel.send(
NavigationIntent.NavigateTo(
route = route,
popUpToRoute = popUpToRoute,
inclusive = inclusive,
isSingleTop = isSingleTop,
)
)
}
override fun tryNavigateTo(
route: String,
popUpToRoute: String?,
inclusive: Boolean,
isSingleTop: Boolean
) {
navigationChannel.trySend(
NavigationIntent.NavigateTo(
route = route,
popUpToRoute = popUpToRoute,
inclusive = inclusive,
isSingleTop = isSingleTop,
)
)
}
}
Обратите внимание, что в примере в качестве паттерна DI используется Dagger-Hilt
, но вы вольны выбрать любой другой.
Теперь реализуем MainScreen
:
@Composable
fun MainScreen(
mainViewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()
NavigationEffects(
navigationChannel = mainViewModel.navigationChannel,
navHostController = navController
)
MediumReposTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
NavHost(
navController = navController,
startDestination = Destination.HomeScreen
) {
composable(destination = Destination.HomeScreen) {
HomeScreen()
}
composable(destination = Destination.UsersScreen) {
UsersScreen()
}
composable(destination = Destination.UserDetailsScreen) {
UserDetailsScreen()
}
composable(destination = Destination.MessagesScreen) {
MessagesScreen()
}
composable(destination = Destination.DetailsScreen) {
DetailsScreen()
}
}
}
}
}
@Composable
fun NavigationEffects(
navigationChannel: Channel<NavigationIntent>,
navHostController: NavHostController
) {
val activity = (LocalContext.current as? Activity)
LaunchedEffect(activity, navHostController, navigationChannel) {
navigationChannel.receiveAsFlow().collect { intent ->
if (activity?.isFinishing == true) {
return@collect
}
when (intent) {
is NavigationIntent.NavigateBack -> {
if (intent.route != null) {
navHostController.popBackStack(intent.route, intent.inclusive)
} else {
navHostController.popBackStack()
}
}
is NavigationIntent.NavigateTo -> {
navHostController.navigate(intent.route) {
launchSingleTop = intent.isSingleTop
intent.popUpToRoute?.let { popUpToRoute ->
popUpTo(popUpToRoute) { inclusive = intent.inclusive }
}
}
}
}
}
}
}
В MainScreen
применяем пользовательские элементы NavHost
и composable
. Запоминаем navController
, который передаем в NavigationEffects
наряду с navigationChannel
из MainViewModel
. NavigationEffects
получает сведения из navigationChannel
и переходит к нужному экрану. Как видно, данный способ более оптимальный и не требует передачи обратных вызовов и navController
.
С MainViewModel
все просто: получаем navigationChannel
из AppNavigator
.
@HiltViewModel
class MainViewModel @Inject constructor(
appNavigator: AppNavigator
) : ViewModel() {
val navigationChannel = appNavigator.navigationChannel
}
Осталось только показать, как вызываются функции навигатора. Обратимся к примеру HomeViewModel
:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val appNavigator: AppNavigator
) : ViewModel() {
fun onNavigateToUsersButtonClicked() {
appNavigator.tryNavigateTo(Destination.UsersScreen())
}
fun onNavigateToMessagesButtonClicked() {
appNavigator.tryNavigateTo(Destination.MessagesScreen())
}
fun onNavigateToDetailsButtonClicked() {
appNavigator.tryNavigateTo(Destination.DetailsScreen())
}
}
HomeScreen
вызывает соответствующие функции. В HomeViewModel
мы вызываем функции AppNavigator
и задействуем Destination
в качестве аргумента для маршрутов.
UsersViewModel
демонстрирует примеры обратной навигации и передачи параметров маршруту:
@HiltViewModel
class UsersViewModel @Inject constructor(
private val appNavigator: AppNavigator
) : ViewModel() {
private val _viewState = MutableStateFlow(UsersViewState())
val viewState = _viewState.asStateFlow()
fun onBackButtonClicked() {
appNavigator.tryNavigateBack()
}
fun onUserRowClicked(user: User) {
appNavigator.tryNavigateTo(
Destination.UserDetailsScreen(
fistName = user.firstName,
lastName = user.lastName
)
)
}
}
На этом все!
Заключение
Уверен, что предложенное решение послужит хорошей отправной точкой для последующих улучшений. Код стал намного чище, отпала необходимость накапливать побочные эффекты из ViewModel
на экранах для обеспечения навигации.
Можно оспорить необходимость выполнения навигации из ViewModel
, но я на этом настаиваю. View
должен быть “глупеньким” и просто показывать данные. А все, что мы делаем при нажатии, должно попадать в зону ответственности ViewModel
.
Полный вариант исходного кода предоставлен в репозитории GitHub.
Читайте также:
- Jetpack Compose: пользовательский интерфейс Twitter
- Роль Fragments в современной разработке приложений для Android
- Поддержка новых форм-факторов с помощью новой библиотеки Jetpack WindowManager
Читайте нас в Telegram, VK и Дзен
Перевод статьи Igor Stevanovic: Jetpack Compose Clean Navigation