What is the proper way to navigate from ViewModel in Jetpack Compose + Hilt + ViewModel?

3 min read 05-10-2024
What is the proper way to navigate from ViewModel in Jetpack Compose + Hilt + ViewModel?


Navigating from ViewModels in Jetpack Compose with Hilt

Navigating between screens in Jetpack Compose applications often involves managing the navigation flow from your ViewModels. This is especially true when using Hilt for dependency injection, as it allows for cleaner and more testable code. This article will delve into the best practices for navigating from ViewModels in a Compose application using Hilt.

Understanding the Problem

The challenge lies in decoupling the UI from navigation logic. Directly calling navigation functions within composables can lead to tightly coupled code and difficulty testing. Instead, we want to use ViewModels to manage the navigation state and trigger transitions from there.

Scenario and Original Code

Let's consider a simple example:

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    Column {
        Text("Welcome to the App!")
        Button(onClick = {
            // **Directly calling navigation from composable** 
            navController.navigate("profile") 
        }) {
            Text("Go to Profile")
        }
    }
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val navController: NavHostController
) : ViewModel() {
    // ViewModel logic here
}

In this example, the navigation action is triggered within the HomeScreen composable, directly referencing the navController. This approach can lead to code that's difficult to test and maintain.

The Solution: Using Navigation Events

The recommended approach is to use a navigation event mechanism within the ViewModel. This allows the UI to react to navigation events triggered by the ViewModel.

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    Column {
        Text("Welcome to the App!")
        Button(onClick = {
            viewModel.onNavigateToProfile()
        }) {
            Text("Go to Profile")
        }
    }
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val navController: NavHostController
) : ViewModel() {

    private val _navigationEvent = MutableStateFlow<NavigationEvent?>(null)
    val navigationEvent: StateFlow<NavigationEvent?> = _navigationEvent.asStateFlow()

    fun onNavigateToProfile() {
        _navigationEvent.value = NavigationEvent.NavigateToProfile
    }

    // Other ViewModel logic
}

sealed class NavigationEvent {
    object NavigateToProfile : NavigationEvent()
}

In this revised code:

  1. We define a sealed class NavigationEvent to represent different navigation actions.
  2. The HomeViewModel exposes a navigationEvent state flow to the composable.
  3. The onNavigateToProfile function updates the navigationEvent state flow with the NavigateToProfile event.
  4. The HomeScreen composable observes the navigationEvent and reacts accordingly.

How it Works

  1. Event Trigger: When the user clicks the "Go to Profile" button, the onNavigateToProfile function in the HomeViewModel is called.
  2. State Update: This function updates the navigationEvent state flow with the NavigateToProfile event.
  3. Composable Reaction: The HomeScreen composable observes the navigationEvent state flow and reacts when the event is emitted.
  4. Navigation Execution: You can use a LaunchedEffect within the composable to listen for navigation events and navigate accordingly.

Example with LaunchedEffect

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    // ... (rest of the composable code)

    LaunchedEffect(key1 = viewModel.navigationEvent) {
        viewModel.navigationEvent.collect { event ->
            when (event) {
                is NavigationEvent.NavigateToProfile -> navController.navigate("profile")
                null -> {}
            }
        }
    }
}

This approach ensures that navigation actions are triggered by the ViewModel, keeping the composable clean and focused on UI rendering.

Benefits of this approach:

  • Testability: You can easily test the ViewModel's navigation logic without interacting with the UI.
  • Maintainability: Decoupling navigation logic from composables leads to cleaner code and easier modifications.
  • State Management: The ViewModel manages navigation events, ensuring consistent and predictable navigation behavior.

Important Considerations:

  • Navigation Destination: Ensure your navigation destination (e.g., "profile") matches the route defined in your NavHost.
  • Clearing Navigation Events: After navigating, remember to clear the navigationEvent to avoid unintended re-navigation.

By implementing this approach, you can effectively handle navigation from your ViewModels in Jetpack Compose applications using Hilt. This results in cleaner, more maintainable, and testable code.

Resources: