Jetpack Compose , mutable state causes infinite recomposition when navigating with NavHost
I

1

5

Here is the code that causes the infinite recomposition problem

MainActivity

class MainActivity : ComponentActivity() {
    
     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        setContent {
                val navController = rememberNavController()
                val viewModel : MainViewModel by viewModel()
                val state by viewModel.state.observeAsState()

                NavHost(navController = navController, startDestination = "firstScreen") {
                    composable("firstScreen") { FirstScreen(
                        navigate = {
                        navController.navigate("secondScreen")
                    }, updateState = {
                            viewModel.getState()
                       },
                       state

                    )}

                    composable("secondScreen") { SecondScreen() }
                }
        }
    }
}

ViewModel

class MainViewModel : ViewModel() {

    //var state = MutableStateFlow(0)

    private val _state = MutableLiveData(0)
    val state: LiveData<Int> = _state

    fun getState()  {
        _state.value = 1
    }
}

First Screen

@Composable
fun FirstScreen(
    navigate: () -> Unit,
    updateState: () -> Unit,
    state: Int?
) {

    Log.e("state",state.toString())

    Button(onClick = {
        updateState()
    }) {
        Text(text = "aaaaaaaa")
    }

    if(state == 1) {
        Log.e("navigate",state.toString())
        navigate()
    }
}

Second Screen

@Composable
fun SecondScreen() {...}

Pressing the button changes the state in the view model and in reaction if it changes to 1 it triggers navigation to the second screen but the first screen is infinitely recomposed and blocks the whole process

Edit

@Composable
fun FirstScreen(
    navigate: () -> Unit,
    updateState: () -> Unit,
    state: Int?
) {


    Log.e("state",state.toString())

    Button(onClick = {
        updateState()
    }) {
        Text(text = "aaaaaaaa")
    }

    LaunchedEffect(state) {

        if (state == 1) {
            Log.e("navigate", state.toString())
            navigate()
        }
    }

}

this solved the problem

Isoleucine answered 27/10, 2022 at 11:46 Comment(0)
U
5

It's because you are navigating based on a conditional property which is part of your FirstScreen composable and changes to that property are outside of the FirstScreen's scope, if that conditional property's value doesn't change, it will always evaluate its block every time the NavHost updates, in your case state remains 1 and will always executes its block.

if(state == 1) {
    ...
    navigate() // navigation
}

What you experience can be summarized by the events broken down below:

  • Navhost configures FirstScreen and SecondScreen (initial NavHost composition)
  • FirstScreen observes an integer state with a value of 0
  • state becomes 1 after you click the button
  • FirstScreen re-composes, satisfies the condition (state==1), executes navigation for the 1st time
  • NavHost re-composes
  • FirstScreen's state remains 1, still satisfies the condition (state==1), executes navigation again for the 2nd time
  • NavHost re-composes
  • FirstScreen's state remains 1, satisfies the condition (state==1), executes navigation again for the 3rd time
  • and the cycle never ends..

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.

I would advice considering navigation as a one-time event, doing it inside LaunchedEffect and observed from a SharedFlow emission. Below is a short workaround to your problem.

Have a sealed class UiEvent,

sealed class UiEvent {
    data class Navigate(val params: Any?): UiEvent()
}

modify your ViewModel like this

class MainViewModel : ViewModel() {

    ...

    private val _oneTimeEvent = MutableSharedFlow<UiEvent>()
    val oneTimeEvent = _oneTimeEvent.asSharedFlow()

    ...

    fun navigate()  {
        if (_state.value == 1) {
            viewModelScope.launch {
                _oneTimeEvent.emit(UiEvent.Navigate(1))
            }
        }

    }
}

, then observe it via LaunchedEffect in your FirstScreen

@Composable
fun FirstScreen(
    navigate: () -> Unit,
    ..
) {
    ...
    ...
    
    LaunchedEffect(Unit) {
        mainViewModel.oneTimeEvent.collectLatest { uiEvent ->
            when (uiEvent) {
                is UiEvent.Navigate -> {
                    navigate()
                }
            }
        }
    }
}

Please see my answer here

Ussery answered 27/10, 2022 at 12:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.