Managing Navigation in Jetpack Compose Using ViewModel: A Scalable Approach
Introduction
In modern Android development, Jetpack Compose offers a declarative way to build UIs, but managing navigation between different screens can still be tricky, especially when trying to maintain a clean architecture. Moving the navigation logic into the ViewModel
allows for better separation of concerns and more testable code.
This blog post walks through a robust solution for handling navigation in Jetpack Compose directly from the ViewModel
, using a shared navigation mechanism for the entire app.
Why Move Navigation to the ViewModel?
Traditionally, navigation is handled in the composables themselves using NavController
, which can clutter your UI code and make it harder to maintain and test. By moving the navigation logic to the ViewModel
, you can:
- Improve Separation of Concerns: The UI becomes focused solely on rendering, while the
ViewModel
takes care of navigation and business logic. - Increase Testability: Navigation logic in the
ViewModel
is easier to test independently. - Reusable Navigation Code: By using a centralized navigation handler, you avoid redundant code across multiple composables.
Navigation with ViewModel
: Code Implementation
Below is a detailed guide on how to implement navigation using ViewModel
and shared flow patterns, making it reusable across the app.
Step 1: Define the Destination
Interface
The Destination
sealed interface defines the different routes (screens) in the app. It serves as a type-safe way to specify the navigation paths, preventing hardcoded strings and reducing the chance of errors.
sealed interface Destination {
@Serializable
data object HomeGraph : Destination
@Serializable
data object AuthGraph : Destination
@Serializable
data object LoginScreen : Destination
@Serializable
data object RegisterScreen : Destination
@Serializable
data object HomeScreen : Destination
@Serializable
data class DetailScreen(val id: String) : Destination
}
Each data object
or data class
represents a screen or a section of the app (e.g., HomeGraph
for home-related screens and AuthGraph
for authentication-related screens).
Step 2: Define NavigationAction
NavigationAction
is a sealed interface that defines the possible navigation actions: navigating to a specific destination or navigating up in the back stack.
sealed interface NavigationAction {
data class Navigate(
val destination: Destination,
val navOptions: NavOptionsBuilder.() -> Unit = {}
) : NavigationAction
data object NavigateUp : NavigationAction
}
This structure ensures that navigation events are handled consistently throughout the app.
Step 3: Implement the Navigator
Interface
The Navigator
interface defines how navigation events are emitted and handled. This interface provides two methods: navigate()
to move between screens and navigateUp()
to handle back navigation.
interface Navigator {
val startDestination: Destination
val navigationActions: Flow<NavigationAction>
suspend fun navigate(destination: Destination, navOptions: NavOptionsBuilder.() -> Unit = {})
suspend fun navigateUp()
}
Step 4: Implement the Default Navigator
The DefaultNavigator
class implements the Navigator
interface. It uses a Channel
to emit navigation events as Flow
, which the composables will observe.
class DefaultNavigator(override val startDestination: Destination) : Navigator {
private val _navigationActions = Channel<NavigationAction>()
override val navigationActions = _navigationActions.receiveAsFlow()
override suspend fun navigate(destination: Destination, navOptions: NavOptionsBuilder.() -> Unit) {
_navigationActions.send(NavigationAction.Navigate(destination, navOptions))
}
override suspend fun navigateUp() {
_navigationActions.send(NavigationAction.NavigateUp)
}
}
Step 5: Observe Navigation Events in Composables
In the composables, we observe the navigationActions
from the Navigator
and react accordingly. The ObserveAsEvents()
composable listens to the Flow
of navigation events and triggers the appropriate actions using NavController
.
@Composable
fun <T> ObserveAsEvents(
flow: Flow<T>,
key1: Any? = null,
key2: Any? = null,
onEvent: (T) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(key1 = lifecycleOwner.lifecycle, key1, key2) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) {
flow.collect(onEvent)
}
}
}
}
Step 6: Configure the Navigation in MainActivity
In MainActivity
, we set up the navigation graph using NavHost
, where we define how different destinations are connected and how navigation is triggered by the Navigator
.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val navController = rememberNavController()
val navigator = koinInject<Navigator>()
ObserveAsEvents(flow = navigator.navigationActions) { action ->
when (action) {
is NavigationAction.Navigate -> navController.navigate(action.destination) {
action.navOptions(this)
}
NavigationAction.NavigateUp -> navController.navigateUp()
}
}
NavHost(
navController = navController,
startDestination = navigator.startDestination,
modifier = Modifier.padding(innerPadding)
) {
navigation<Destination.AuthGraph>(startDestination = Destination.LoginScreen) {
composable<Destination.LoginScreen> {
val viewModel = koinViewModel<LoginViewModel>()
LoginComposable(viewModel = viewModel)
}
composable<Destination.RegisterScreen> {
val viewModel = koinViewModel<RegisterViewModel>()
RegisterComposable(viewModel = viewModel)
}
}
navigation<Destination.HomeGraph>(startDestination = Destination.HomeScreen) {
composable<Destination.HomeScreen> {
val viewModel = koinViewModel<HomeViewModel>()
HomeComposable(viewModel = viewModel)
}
composable<Destination.DetailScreen> {
val viewModel = koinViewModel<DetailViewModel>()
val args = it.toRoute<Destination.DetailScreen>()
DetailComposable(viewModel = viewModel, args = args)
}
}
}
}
}
}
}
Step 7: Example LoginViewModel
Navigation Logic
The following example demonstrates how to trigger navigation events inside the ViewModel
. When a user logs in, they are redirected to the HomeGraph
.
class LoginViewModel(
private val navigator: Navigator
) : ViewModel() {
fun eventHandler(uiEvents: LoginUiEvents) {
viewModelScope.launch {
when (uiEvents) {
LoginUiEvents.Login -> {
navigator.navigate(destination = Destination.HomeGraph) {
popUpTo(Destination.AuthGraph) { inclusive = true }
}
}
LoginUiEvents.Register -> {
navigator.navigate(Destination.RegisterScreen)
}
else -> {}
}
}
}
}
Conclusion
This approach centralizes navigation logic in the ViewModel
, making it reusable, testable, and easier to manage in large applications. By using interfaces like Navigator
and handling navigation actions via Flow
, this setup scales well as your app grows and can be extended to handle more complex navigation scenarios.
By decoupling navigation from composables, you achieve cleaner, more maintainable code, aligning with Jetpack Compose’s declarative and reactive programming model.
GitHub Repository
For the complete implementation, you can find the source code in the GitHub repository here.