androidkotlinandroid-jetpack-composekotlin-stateflowmutablestateflow

Compose screen not recompositioning after state change


I have a ViewModel with a stateFlow. Everytime that a button is clicked, I'm changing the value to the state but, for some reason, the screen is not reacting to that change. I know that the state's value does change because when I turn the screen and there's a complete recomposition, it shows what it should show with the value changed, but only after turning the screen. I want the change on the screen without turning the screen.

This is my viewModel:

@HiltViewModel
class CragToposViewModel @Inject constructor() : ViewModel() {
    private var _state = MutableStateFlow(CragToposState(true))
    val state = _state.asStateFlow()
}

This is my models:

data class CragToposState(
    var zoomedOutImage: Boolean,
)

And this is my screen:

@Composable
fun CragToposScreen(
    navController: NavController,
    cragToposViewModel: CragToposViewModel = hiltViewModel(),
) {
    val state = cragToposViewModel.state.collectAsState()

    CragToposContent(
        navController = navController,
        state = state,
    )
}

@Composable
fun CragToposContent(
    navController: NavController,
    state: State<CragToposState>,
) {
    val context = LocalContext.current
    val scrollState = rememberScrollState()

    if (state.value.zoomedOutImage) {
        // SOME OTHER STUFF HERE
        ZoomButton(state)
    } else {
        // SOME OTHER STUFF HERE
        ZoomButton(state)
    }
}

@Composable
fun ZoomButton(state: State<CragToposState>) {
    Box(
        modifier = Modifier
            .clickable { state.value.zoomedOutImage = !state.value.zoomedOutImage },
    ) {
        Icon(
            painter = if (state.value.zoomedOutImage)
                painterResource(R.drawable.icon_zoom_in)
            else painterResource(R.drawable.icon_zoom_out),
            contentDescription = "",
        )
    }
}

The way I'm setting the new value to the state, is the correct way?

Do I need a StateFlow here? I just want the state to change after pressing a button

Why do I have to use State<CragToposState> and can't just use CragToposState on fun CragToposContent?


Solution

  • The main issue is that Compose cannot detect the changed zoomedOutImage and therefore doesn't recompose, i.e. the UI is never updated.

    There are multiple problems in your code that need to be addressed:

    1. The StateFlow's content needs to be immutable. CragToposState contains a var property, that must be replaced with val:

      data class CragToposState(
          val zoomedOutImage: Boolean,
      )
      
    2. Don't pass State objects around, only pass their value:

      @Composable
      fun CragToposContent(
          navController: NavController,
          state: CragToposState,
      ) { /*...*/ }
      
      @Composable
      fun ZoomButton(
          zoomedOutImage: Boolean,
      ) { /*...*/ }
      
    3. Changes to the state must be performed by the view model:

      fun toggleZoomedState() {
          _state.update {
              it.copy(zoomedOutImage = !it.zoomedOutImage)
          }
      }
      

      Pass this function to the composables that need it:

      @Composable
      fun CragToposContent(
          navController: NavController,
          state: CragToposState,
          toggleZoomedState: () -> Unit,
      ) { /*...*/ }
      
      @Composable
      fun ZoomButton(
          zoomedOutImage: Boolean,
          toggleZoomedState: () -> Unit,
      ) { /*...*/ }
      

    Everything put together, your composables should look like this:

    @Composable
    fun CragToposScreen(
        navController: NavController,
        cragToposViewModel: CragToposViewModel = hiltViewModel(),
    ) {
        val state by cragToposViewModel.state.collectAsStateWithLifecycle()
    
        CragToposContent(
            navController = navController,
            state = state,
            toggleZoomedState = cragToposViewModel::toggleZoomedState,
        )
    }
    
    @Composable
    fun CragToposContent(
        navController: NavController,
        state: CragToposState,
        toggleZoomedState: () -> Unit,
    ) {
        val context = LocalContext.current
        val scrollState = rememberScrollState()
    
        if (state.zoomedOutImage) {
            // SOME OTHER STUFF HERE
        } else {
            // SOME OTHER STUFF HERE
        }
    
        ZoomButton(
            zoomedOutImage = state.zoomedOutImage,
            toggleZoomedState = toggleZoomedState,
        )
    }
    
    @Composable
    fun ZoomButton(
        zoomedOutImage: Boolean,
        toggleZoomedState: () -> Unit,
    ) {
        Box(
            modifier = Modifier
                .clickable(onClick = toggleZoomedState),
        ) {
            Icon(
                painter = if (zoomedOutImage)
                    painterResource(R.drawable.icon_zoom_in)
                else painterResource(R.drawable.icon_zoom_out),
                contentDescription = "",
            )
        }
    }
    

    You need to use a StateFlow because you need an observable data structure. Compose provides MutableState for that, but that doesn't properly work in view models. In a view model the way to go is to use a StateFlow and then convert it to a State in your composables by calling collectAsStateWithLifecycle (you should prefer this to collectAsState to properly unsubscribe from the flow when it is not needed).

    If you just want to toggle the zoomed state you do not even need a view model or CragToposState, a simple Boolean, remembered and wrapped in a MutableState would suffice:

    @Composable
    fun CragToposContent(
        navController: NavController,
    ) {
        val context = LocalContext.current
        val scrollState = rememberScrollState()
        var zoomedOutImage by rememberSaveable { mutableStateOf(true) }
    
        if (zoomedOutImage) {
            // SOME OTHER STUFF HERE
        } else {
            // SOME OTHER STUFF HERE
        }
    
        ZoomButton(zoomedOutImage) {
            zoomedOutImage = !zoomedOutImage
        }
    }