Home > database >  ViewModel triggered navigation with JetpackCompose
ViewModel triggered navigation with JetpackCompose

Time:11-11

In Android I often want to navigate is response to state change from a ViewModel. (for example, successful authentication triggers navigation to the user's home screen.)

Is the best practice to trigger navigation from within the ViewModel? Is there an intentional mechanism to trigger navigation within a composable in response to a ViewModel state change?

With Jetpack Compose the process for handling this use case is not obvious. If I try something like the following example navigation will occur, but the destination I navigate to will not behave correctly. I believe this is because the original composable function was not allowed to finish before navigation was invoked.

// Does not behave correctly.
@Composable fun AuthScreen() {
    val screenState = viewModel.screenState.observeAsState()
    if(screenState.value is ScreenState.UserAuthenticated){
        navController.navigate("/gameScreen")
    } else {
        LoginScreen()
    }
}

I do observe the correct behavior if I use LauncedEffect as follows:

// Does behave correctly.
@Composable fun AuthScreen() {
    val screenState = viewModel.screenState.observeAsState()
    if(screenState.value is ScreenState.UserAuthenticated){
        LaunchedEffect(key1 = "test") {
            navController.navigate("$/gameScreen")
        }
    } else {
        LoginScreen()
    }
}

Is this correct? The documentation for LaunchedEffect states the following, but the meaning is not 100% clear to me:

When LaunchedEffect enters the composition it will launch block into the composition's CoroutineContext. The coroutine will be cancelled and re-launched when LaunchedEffect is recomposed with a different key1, key2 or key3. The coroutine will be cancelled when the LaunchedEffect leaves the composition.

CodePudding user response:

This code

// Does not behave correctly.
@Composable fun AuthScreen() {
    val screenState = viewModel.screenState.observeAsState()
    if(screenState.value is ScreenState.UserAuthenticated){
        navController.navigate("/gameScreen")
    } else {
        LoginScreen()
    }
}

which does not behave correctly is most likely causing an issue like this, and one of the ways to solve it is by this

// Does behave correctly.
@Composable fun AuthScreen() {
    val screenState = viewModel.screenState.observeAsState()
    if(screenState.value is ScreenState.UserAuthenticated){
        LaunchedEffect(key1 = "test") {
            navController.navigate("$/gameScreen")
        }
    } else {
        LoginScreen()
    }
}

which behaves correctly, because LaunchedEffect is guaranteed to execute only once per composition assuming its key won't change on the next composition pass, otherwise it will keep executing on every update of its composable scope.

I would suggest considering the "correct" not only based on suggested components but thinking how to avoid navigation pitfalls like the link I provided.

It won't matter if it's coming from a ViewModel or some flow emissions but the idea for a safe navigation in compose (so far as I understand it) is to make sure that the navigation call will only happen in a block that will never re-execute on succeeding re-compositions, which this one also suffers from the first type of code above.

CodePudding user response:

Why not let the View control navigation?

In Google’s Jetpack examples, navigation is triggered from the composable View. As a result, control of the screen state is shared between the View and the ViewModel. Using the ViewModel to control navigation results in a single source of truth. For example, when the ViewModel starts an asynchronous action and wants to navigate after the action finishes, it can cleanly and easily do so.

Singleton Navigation Manager (alternative)

Joe Birch, among others, describes a singleton navigation manager to handle ViewModel-initiated navigation. This implementation is simple but has one vulnerability. Any ViewModel (or any class) can trigger navigation, not just the screen that is currently in view. This can cause ViewModels in the back stack to start navigation. For example, after an async action finishes. This could result in hard-to-find issues.

Active screens listen to Navigation

When we let screens listen to their own ViewModel and navigation state, ViewModels in the back stack cannot initiate navigation because they don’t have a view. Only when the view attached to a ViewModel is resumed, then the ViewModel’s navigation logic can be triggered.

When we abstract away the logic that is needed for this approach, then the implementation can be simple and short. But before we do so, let’s discuss the state and events.

  • Related