How to trigger navigation in Jetpack Compose outside of composables
Using the Jetpack compose navigation library is a valid choice when moving from screen to screen. It allows to register several navigation destinations and to reference them by a path. The key to triggering such a navigation is the
NavHostController
. It offers several methods like navController.navigate("screenName")
. This is fine but the navController
is a composable and therefore has to be triggered in the context of another composable. But what if you want to trigger navigation from a ViewModel
? Possibly after a long-running coroutine? In this blog post, we will discuss a possible solution for this scenario.
The key idea is to separate the navigation command into a stateful entity which can then be observed by the navController
. For that, we create a Navigator
class, which exposes the current navigation location and offers a navigate("route")
method. First, we define our NavigationDestination
. In our case, we have a login screen and the main screen.
1 2 3 4 5 6 7 8 9 10 11 12 |
interface NavigationDestination { val route: String } sealed class Screen(override val route: String) : NavigationDestination { object LoginScreen : Screen("login") object MainScreen : Screen("main") } |
Note that in a real app, the NavigationDestination
would also contain arguments to be passed to the destination page. Next comes the Navigator
, which exposes the current destination
with the default screen of LoginScreen
.
1 2 3 4 5 6 7 8 9 |
class Navigator { var destination: MutableStateFlow<NavigationDestination> = MutableStateFlow(Screen.LoginScreen) fun navigate(destination: NavigationDestination) { this.destination.value = destination } } |
The Navigator
should be provided as a service via a suitable dependency injection framework since we want to consume it across our app.
Now we are able to apply the configured navigation state. In our main composable, we obtain the NavHostController
and listen for changes to our Navigator
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
val navController = rememberNavController() val destination by navigator.destination.collectAsState() LaunchedEffect(destination) { if (navController.currentDestination?.route != destination.route) { navController.navigate(destination.route) } } NavHost( navController = navController, startDestination = navigator.destination.value.route ) { composable(MainScreen.route) { MainScreen() } composable(LoginScreen.route) { LoginScreen() } } |
The most important part is the consumption of the navigator.destination
as observable state. Whenever the app changes the navigation destination, we want to react to the change and invoke the navController.navigate(..)
. To not do so during recomposition, we wrap the navigation in a LaunchedEffect
, which ensures the navigation is triggered only once. Additionally, we check the current route of the navController
to not perform duplicate navigations on launch. Finally, we set the current (default navigation destination) as the startDestination
of the NavHost
composable.
Now we are able to perform decoupled navigation in our code by invoking the Navigator
like so.
1 2 3 4 5 6 7 8 9 10 |
class LoginViewModel(private val navigator: Navigator) : ViewModel() { fun login(username: String, password: String) { viewModelScope.launch { // long running login process happens here navigator.navigate(MainScreen) } } } |
If you found this approach helpful, please let us know in the comments below. How do you handle navigation in jetpack compose? Do you have another mechanism? Again, let us know in the comments below.
Feedback is welcome!
Want to join the discussion?Feel free to contribute!
Leave a Reply
Want to join the discussion?Feel free to contribute!