Sitemap

Managing Navigation in Jetpack Compose Using ViewModel: A Scalable Approach

4 min readOct 1, 2024

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.

--

--

Yogesh Mahida
Yogesh Mahida

Written by Yogesh Mahida

Experienced Android developer for 5 years, specializing in Kotlin and Jetpack Compose. Passionate about crafting user-centric and innovative mobile applications

Responses (2)