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

4

16

I have stumbled upon this quite trivial, but tricky problem. I have spent a decent amount of time searching official docs, but unfortunately found no answer.

Official docs say that you should pass an instance of NavController down to @Composable-s, and call it as onClick = { navController.navigate("path") }. But what happens if I have to trigger navigation event from ViewModel (ex. redirect on login, redirect to newly created post page)? Awaiting any coroutine (ex. HTTP request) in @Composable would be not just bad, but probably force Android to kill app because of the blocked UI thread

Unofficial solutions (documented mostly if form of Medium articles) are based on the concept of having a singleton class and observing some MutableStateFlow containing path.

That sounds stupid in theory, and doesn't help much in practice (not side-effect and recomposition friendly, triggers unnecessary re-navigation).

Keratinize answered 26/4, 2022 at 20:41 Comment(5)
Did you read the Events in Compose guide which specifically covers this case?Ellene
Yes, I did, but I am not sure how is that supposed to fix my problem? So far, I haven't found a concrete example of navigation triggered from ViewModel method.Gaslight
MutableStateFlow containing path is the right wayBarnette
You could use this solution (disclaimer, I wrote it), it adds quite some code (once) though. medium.com/@ffvanderlaan/…Malnutrition
You can pass a command from a Channel(UNLIMITED) that you observe from a launched effectReport
S
11

I have been struggling with the exact same question myself. From the limited documentation Google provided on this topic, specifically the architecture events section I'm wondering if what they're suggesting is to use a state as a trigger for navigation?

Quoting the document:

For example, when implementing a sign-in screen, tapping on a Sign in button should cause your app to display a progress spinner and a network call. If the login was successful, then your app navigates to a different screen; in case of an error the app shows a Snackbar. Here's how you would model the screen state and the event:

They have provided the following snippet of code for the above requirement:

sealed class UiState {
    object SignedOut : UiState()
    object InProgress : UiState()
    object Error : UiState()
    object SignIn : UiState()
}

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(SignedOut)
    val uiState: State<UiState>
        get() = _uiState
}

What they did not provide is the rest of the view model and compose code. I'm guessing it's supposed to look like:

@Composable
fun MyScreen(navController: NavController, viewModel: MyViewModel) {
    when(viewModel.uiState){
        is SignedOut ->  // Display signed out UI components
        is InProgress -> // Display loading spinner
        is Error ->      // Display error toast

        // Using the SignIn state as a trigger to navigate
        is SignIn ->     navController.navigate(...)  
    }
}

Also the view model could have a function like this one (trigger by clicking a "sign in" button from compose screen

fun onSignIn() {
    viewModelScope.launch {
        // Make a suspending sign in network  call
        _uiState.value = InProgress

         // Trigger navigation
        _uiState.value = SignIn
    }
}
Stannfield answered 31/5, 2022 at 11:20 Comment(0)
C
6

The rememberNavController has a pretty simple source code that you can use to create it in a singleton service:

@Singleton
class NavigationService @Inject constructor(
    @ApplicationContext context: Context,
) {
    val navController = NavHostController(context).apply {
        navigatorProvider.addNavigator(ComposeNavigator())
        navigatorProvider.addNavigator(DialogNavigator())
    }
}

Create a helper view model to share NavHostController with NavHost view:

@HiltViewModel
class NavViewModel @Inject constructor(
    navigationService: NavigationService,
): ViewModel() {
    val controller = navigationService.navController
}

NavHost(
    navController = hiltViewModel<NavViewModel>().controller,
    startDestination = // ...
) {
    // ...
}

Then in any view model you can inject it and use for navigation:

@HiltViewModel
class ScreenViewModel @Inject constructor(
    private val navigationService: NavigationService
): ViewModel() {
    fun navigateToNextScreen() {
        navigationService.navController.navigate(Destinations.NextScreen)
    }
}
Cotter answered 7/5, 2022 at 14:24 Comment(5)
This works properly unless I close app by pressing back button - then the app crashes with ``` java.lang.IllegalStateException: ViewModelStore should be set before setGraph call at androidx.navigation.NavController.setViewModelStore(NavController.kt:2164) at androidx.navigation.NavHostController.setViewModelStore(NavHostController.kt:101) ```Gaslight
@BorisavŽivanović have you found a solution for this?Kike
@GonçaloGaspar I have found something that kinda worked (it was a student project, so perfection wasn't a priority), but didn't find it elegant enough to share it here. But I can share the code tonight, as I don't have time to format it and explain now. It was a simplified version of Nikola Spiric's answer, as I didn't find his exact pattern a good fit for my case.Gaslight
@BorisavŽivanović ok thank you, no need. I ended up doing this #69276665Kike
Yes, that's basically the same as my solution.Gaslight
C
1

I went a similar way than @Phil Dukhov. I created a wrapper class that copies the code already found in rememberNavController():

class NavigationService constructor(
    context: Context,
) {
    val navController = NavHostController(context).apply {
        navigatorProvider.addNavigator(ComposeNavigator())
        navigatorProvider.addNavigator(DialogNavigator())
    }
}

Then using Hilt I created a provider for my NavHostController. As I needed my navController to traverse a nested NavHost I decided to scope it to the ViewModel

@Module
@InstallIn(ViewModelComponent::class)
object NavigationModule {
    @Provides
    fun provideNestedNavController(@ApplicationContext context: Context): NavHostController {
        return NavigationService(context).navController}
}

This allows me to inject the navcontroller directly into my viewmodel and trigger navigation from within. I can then access the NavController from my composables in the following way:

val navController: NavHostController = viewModel.navController  

in order to build the nested NavGraph

Cottrill answered 1/5, 2023 at 22:24 Comment(0)
N
1

I've used this for a few months and haven't encountered any issues. I like it because it's straightforward. My product version uses Koin to inject a NavigationManager singleton (instead of Hilt). To simplify this example, I posted an object-based approach.

sealed interface Route {
    @Serializable
    data object Home : Route

    @Serializable
    data object Screen1 : Route
}

/**
 * Centralized navigation manager that emits screen events
 * to be consumed by the [NavigatorLaunchedEffect] composable.
 */
object NavigationManager {
    private val _route = MutableSharedFlow<Route>()
    val route: SharedFlow<Route> = _route

    fun navigateTo(event: Route) {
        _route.tryEmit(event)
    }

    suspend fun navigateToAsync(event: Route) {
        _route.emit(event)
    }
}

/**
 * This composable LaunchedEffect collects navigation events
 * from the [NavigationManager] and navigates to the next
 * emitted screen.
 */
@Composable
fun NavigatorLaunchedEffect(
    navController: NavHostController,
) {
    LaunchedEffect("NavigationEvents") {
        NavigationManager.route.collect { screen ->
            navController.navigate(screen)
        }
    }
}

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

                    // Listen and respond to navigation events.
                    NavigatorLaunchedEffect(navController = navController)

                    NavHost(navController = navController, startDestination = Route.Home) {
                        composable<Route.Home> {  
                            HomeScreen()
                        }
                        composable<Route.Screen1> {
                            Screen1()
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun HomeScreen() {
    Button(
        onClick = {
            NavigationManager.navigateTo(Route.Screen1)
        }
    ) {
        Text("Navigate to Screen1")
    }
}

@Composable
fun Screen1() {
    Text("Screen1")
}
Nannana answered 14/6 at 22:44 Comment(2)
This solution is great. We don't need to copy anything and we can simply navigate through events. We can also log the navigation and other stuff.Dextroamphetamine
Thanks! I'm glad it's helpful to you.Nannana

© 2022 - 2024 — McMap. All rights reserved.