Jetpack Compose Navigation loads screen infinitely

I re-implemented your posted code with 2 screens, HomeScreen and SettingScreen and stripped out some part of the UiState class and its usages.

The issue is in your HomeScreen composable, not in the StateFlow emission.

You have this mutableState

val uiState by viewModel.uiState.collectAsStateWithLifecycle(
      initialValue = UiState.Speak
)

that is being observed in one of your when block that executes a navigation callback.

uiState?.let {
         when (it) {
             is UiState.Navigate ->  {
                  onNavigationRequested(it.route)
             }
             UiState.Speak -> {
                 Log.d("UiState", "Speaking....")
            }
}

When your ViewModel function is called

 fun onNavigateButtonClicked(){
        mutableUiState.tryEmit(UiState.Navigate(Destination.SETTINGS_SCREEN.route))
 }

it will update uiState, setting its value to Navigate, observed by HomeScreen, satisfies the when block and then triggers the callback to navigate to the next screen.

Now based on the official Docs,

You should only call navigate() as part of a callback and not as part
of your composable itself, to avoid calling navigate() on every
recomposition.

but in your case, the navigation is triggered by an observed mutableState, and the mutableState is part of your HomeScreen composable.

It seems like when the navController performs a navigation and the NavHost being a Composable

@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    route: String? = null,
    builder: NavGraphBuilder.() -> Unit
) { ... }

it will execute a re-composition, because of it, it will call again the HomeScreen (HomeScreen is not re-composed, its state remains the same) and because the HomeScreen's UiState value is still set to Navigate, it satisfies the when block, triggers again the callback to navigate, and NavHost re-composes, an infinite cycle is then created.

What I did (and its very ugly) is I created a boolean flag inside the viewModel, used it to wrap the callback conditionally,

uiState?.let {
            when (it) {
                is UiState.Navigate  ->  {
                    if (!viewModel.navigated) {
                        onNavigationRequested(it.route)
                        viewModel.navigated = true
                    } else {
                        // dirty empty else 
                    }
                }
                UiState.Speak -> {
                    Log.d("UiState", "Speaking....")
                }
            }
        }

and setting it to true afterwards, preventing the cycle.

I can hardly guess your compose implementation structure but I usually don’t mix my one-time event actions and UiState, instead I have a separate UiEvent sealed class that will group “one-time” events such as the following:

  • Snackbar
  • Toast
  • Navigation

and having them emitted as a SharedFlow emissions because these events doesn’t need any initial state or initial value.

Continuing further, I created this class

sealed class UiEvent {
    data class Navigate(val route: String) : UiEvent()
}

use it in the ViewModel as a type (Navigate in this case),

 private val _event : MutableSharedFlow<UiEvent> = MutableSharedFlow()
 val event = _event.asSharedFlow()

 fun onNavigateButtonClicked(){
        viewModelScope.launch {
            _event.emit(UiEvent.Navigate(Destination.SETTINGS_SCREEN.route))
        }
    }

and observe it in HomeScreen this way via LaunchedEffect, triggering the navigation in it without the callback being bound to any observed state.

LaunchedEffect(Unit) {
        viewModel.event.collectLatest {
            when (it) {
                is UiEvent.Navigate -> {
                    onNavigationRequested(it.route)
                }
            }
        }
    }

This approach doesn’t introduce the infinite navigation cycle and the dirty boolean checking is not needed anymore.

Also have a look this S.O post, similar to your case

Leave a Comment