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
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