androidkotlinandroid-viewmodeldagger-hiltandroid-jetpack-compose-navigation

Hilt Navigation Compose and ViewModel


I tried to follow the examples in the android documentation but I can't implement the navigation using Navigation Compose, ViewModel and Hilt. No errors are thrown and in the NavigationScreen LaunchedEffect isn't triggered on ViewModel emits.

Composables:

@HiltAndroidApp
class MyApplication : Application() {}

@Serializable
object NavScreen

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            val navController = rememberNavController()

            PhotosSyncTheme {
                NavHost(
                    navController = navController,
                    startDestination = NavScreen
                ) {
                    composable<NavScreen> { NavigationScreen(navController) }
                    composable<NavigationEvent.NavigateToHome> { HomeScreen() }
                    composable<NavigationEvent.NavigateToSettings> { Settings() }
                }
            }
        }
    }
}

@Composable
fun NavigationScreen(navController: NavController) {
    val navViewModel = hiltViewModel<NavigationViewModel>()

    val navigationEvent by navViewModel.navigationEvents.collectAsState(initial = NavigationEvent.NavigateToHome)

    LaunchedEffect(navigationEvent) {
        Log.d("Navigation Screen", navigationEvent.toString())
        navigationEvent.let { event ->
            Log.d("Navigation Screen", event.toString())
            when (event) {
                is NavigationEvent.NavigateBack -> navController.popBackStack()
                NavigationEvent.NavigateToHome -> navController.navigate(NavigationEvent.NavigateToHome)
                NavigationEvent.NavigateToSettings -> navController.navigate(NavigationEvent.NavigateToSettings)
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
    val navViewModel = hiltViewModel<NavigationViewModel>()
    var selectedItem by remember { mutableIntStateOf(0) }

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(title = { Text("Photos Sync") }, actions = {
                IconButton(onClick = {
                    navViewModel.navigateToSettings()
                }) {
                    Icon(
                        imageVector = Icons.Filled.Settings,
                        contentDescription = "Settings icon"
                    )
                }
            })
        }
    )
    { }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Settings() {
    val viewModel = hiltViewModel<NavigationViewModel>()

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBarWithBack(title = "Definições")
        }
    )
    { }
}

ViewModel:

@HiltViewModel
class NavigationViewModel @Inject constructor() : ViewModel() {
    private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
    val navigationEvents: SharedFlow<NavigationEvent> = _navigationEvents.asSharedFlow()

    fun navigateBack() {
        viewModelScope.launch { _navigationEvents.emit(NavigationEvent.NavigateBack) }
    }

    fun navigateToHome() {
        viewModelScope.launch { _navigationEvents.emit(NavigationEvent.NavigateToHome) }
    }

    fun navigateToSettings() {
        viewModelScope.launch {
            Log.d("Navigation ViewModel", "Emit Navigate To Settings")
            try {
                _navigationEvents.emit(NavigationEvent.NavigateToSettings)

            } catch (e: Exception) {
                Log.e("Navigation ViewModel", "Error emitting navigation event")
            } finally {
                Log.d("Navigation ViewModel", "done Emit Navigate To Settings")
            }
        }
    }

    fun navigateTo(event: NavigationEvent) {
        viewModelScope.launch { _navigationEvents.emit(event) }
    }

    sealed class NavigationEvent {
        @Serializable
        object NavigateBack : NavigationEvent()

        @Serializable
        object NavigateToHome : NavigationEvent()

        @Serializable
        object NavigateToSettings : NavigationEvent()
    }
}

I was expecting that when I click the settings button the settings composable is pushed to the top of composables stack using navController.navigate


Solution

  • View models are scoped to the current navigation destination (whether you use Hilt or not). You use the same view model for different screens, so each will get its own view model instance. Modifying the state of one will not affect the state of the others.

    You can scope the view model to something else so your destinations would share the same view model instance (see Can I share a ViewModel using hiltViewModel() in different Compose Navigation route?), but I don't see why you even want to involve a view model in the first place since navigation isn't related to the UI state. All your view model currently does is receiving the navigation event and passing it back to the composable. You do not even persist the current route or its parameters (which could be done using a SavedStateHandle), but that is also mostly useful for view models dedicated to a specific screen, not a navigation view model.

    I would recommend removing the NavigationViewModel entirely. You can retain your NavigationEvent sealed hierarchy, although I find the name quite misleading and you do not even need the inheritance. Better replace it with something like this (declared on the top level, without an enclosing interface):

    @Serializable data object HomeRoute
    @Serializable data object SettingsRoute
    

    Since you do not use them as events anymore they are now only used for routes in your nav graph, hence the name. Since NavigateBack is not a route it can be removed (just call navController.popBackStack() when needed).

    Your NavHost would then look like this:

    NavHost(
        navController = navController,
        startDestination = HomeRoute,
    ) {
        composable<HomeRoute> {
            HomeScreen(
                navigateToSettings = { navController.navigate(SettingsRoute) },
            )
        }
    
        composable<SettingsRoute> { Settings() }
    }
    

    Note that instead of HomeScreen relying on the view model to access navigation, the newly created parameter navigateToSettings is passed the necessary navigation logic. You shouldn't pass the navController directly. HomeScreen then uses the new parameter like this:

    @Composable
    fun HomeScreen(
        navigateToSettings: () -> Unit,
    ) {
        // ...
        IconButton(onClick = navigateToSettings) {
            //...
        }
    }
    

    This also makes HomeScreen better testable and reusable since it isn't dependent on the view model anymore.

    The entire navigation logic is now contained in the NavHost. NavigationScreen with the LaunchedEffect can be removed.

    Google provides the fully functional sample project Now in Android that - among other things - also shows how to use more complex navigation. If you want to snoop around you can simply import it in Android Studio ("Get from Version Control...") using this link: https://github.com/android/nowinandroid