Search code examples
androidkotlinandroid-jetpack-compose

Jetpack Compose: How to correctly force recompose when mutableStateOf object is changed from viewModel?


View:

val viewModel = hiltViewModel<ActivityViewModel>()

Text("STATE: ${viewModel.state.activity?.invitation?.state?.title}")

ViewModel:

@HiltViewModel
class ActivityViewModel @Inject constructor(
    private val repository: ActivityRepository,
    @ApplicationContext private val context: Context,
) : ViewModel() {
    var state by mutableStateOf(ActivityScreenState())
        private set

    suspend fun fetchActivity(id: String) {
        val resource = repository.fetchActivity(id)
        val activity = resource.data

        resource.errorMessage?.let {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }

        state = state.copy(
            isLoading = false,
            activity = activity,
        )
    }

    suspend fun accept(invitation: Invitation) {
        val tempActivity = state.activity
        tempActivity?.invitation?.state = InvitationState.ACCEPTED

        state = state.copy(
            activity = tempActivity,
        )
    }
}

ActivityScreenState:

data class ActivityScreenState(
    val isLoading: Boolean = true,
    val activity: Activity? = null,
)

data class Activity(
    val id: String,
    val invitation: Invitation?,
)

data class Invitation(
    val id: String,
    var state: InvitationState,
)

enum class InvitationState(val title: String) {
    ACCEPTED("accepted"),
    DECLINED("declined"),
}

I have a viewModel, ActivityScreenState data class that contains Activity data class. When I update Activity inside the ActivityScreenState it doesn't recompose my composable view but I know it changes if I Log.d it.

I've tried searching but couldn't find what I'm doing wrong. Also found out it only recomposes if I nullify the activity inside ActivityScreenState.

Am I doing something wrong or is this a bug?


Solution

  • The reason is data class uses equals and hashCode for structural equality == and with

    @Suppress("UNCHECKED_CAST")
    fun <T> structuralEqualityPolicy(): SnapshotMutationPolicy<T> =
        StructuralEqualityPolicy as SnapshotMutationPolicy<T>
    
    private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
        override fun equivalent(a: Any?, b: Any?) = a == b
    
        override fun toString() = "StructuralEqualityPolicy"
    }
    
    @StateFactoryMarker
    fun <T> mutableStateOf(
        value: T,
        policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
    ): MutableState<T> = createSnapshotMutableState(value, policy)
    

    You are not updating parameters of ActivityScreenState

    suspend fun accept(invitation: Invitation) {
        val tempActivity = state.activity
        tempActivity?.invitation?.state = InvitationState.ACCEPTED
    
        state = state.copy(
            activity = tempActivity
        )
    }
    

    you are not changing any properties of constructor of

    data class ActivityScreenState(
        val isLoading: Boolean = true,
        val activity: Activity? = null,
    )
    

    you are actually changing parameter of Activity while the instance remains same.

    you should have new activity while you set same activity after changing state = InvitationState.ACCEPTED or you can use referentialEqualityPolicy() which triggers recomposition when new object is assigned.

    suspend fun accept(invitation: Invitation) {
        val newActivity = state.activity.copy(activity = ...new instance here with copy or creating new Activity instance with new invitation)
    
        state = state.copy(
            activity = newActivity
        )
    }
    

    In general if you wish to force update with same value or different reference you can change snapshotMutationPolicies.

    For instance

    enter image description here

    @Preview
    @Composable
    fun ForceRecpompositionSample() {
        Column {
            Composable1()
            Composable2()
        }
    }
    
    @Composable
    fun Composable1() {
        var myCounter by remember {
            mutableStateOf(MyCounter(0))
        }
    
        Column(
            modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
        ) {
            Button(
                onClick = {
                    myCounter = myCounter.copy(value = 5)
                }
            ) {
                Text("Update MyCounter")
    
            }
            Text("Value: ${myCounter.value}")
        }
    }
    
    @Composable
    fun Composable2() {
        var myCounter by remember {
            mutableStateOf(
                value = MyCounter(0),
                policy = referentialEqualityPolicy()
            )
        }
    
        Column(
            modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
        ) {
            Button(
                onClick = {
                    myCounter = myCounter.copy(value = 5)
                }
            ) {
                Text("Update MyCounter")
    
            }
            Text("Value: ${myCounter.value}")
        }
    }
    
    data class MyCounter(val value: Int)
    

    if you check second composable you will see that you trigger recomposition even by setting same value of MyCounter while default one doesn't in Composable1.

    getRandom color is a function that returns new color on recomposition to observer recomposition visually

    fun getRandomColor() =  Color(
        red = Random.nextInt(256),
        green = Random.nextInt(256),
        blue = Random.nextInt(256),
        alpha = 255
    )
    

    You can even force recomposition with Int or String values as well if change neverEquals policy such as

    @Preview
    @Composable
    fun ForceRecompositionSample2() {
        var counter by remember {
            mutableStateOf(
                value = 0,
                policy = neverEqualPolicy()
            )
        }
    
        Column(
            modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
        ) {
            Button(
                onClick = {
                    counter = 5
                }
            ) {
                Text("Update MyCounter")
    
            }
            Text("Value: ${counter}")
        }
    }
    

    If you wish to go with data class route you can update previous example as

    data class MyCounter(
        val value: Int,
        val innerCounter: InnerCounter = InnerCounter()
    )
    
    data class InnerCounter(var value: Int = 0)
    

    And to update InnerCounter and trigger recomposition check Composable2

    @Composable
    fun Composable1() {
        var myCounter by remember {
            mutableStateOf(MyCounter(0))
        }
    
        Column(
            modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
        ) {
    
            Button(
                onClick = {
                    val innerCounter = myCounter.innerCounter
                    val newValue = innerCounter.value + 1
                    innerCounter.value = newValue
                    myCounter = myCounter.copy()
                }
            ) {
                Text("Update MyCounter")
    
            }
            Text("Value: ${myCounter.value}")
        }
    }
    
    @Composable
    fun Composable2() {
        var myCounter by remember {
            mutableStateOf(
                value = MyCounter(0)
            )
        }
    
        Column(
            modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
        ) {
            Button(
                onClick = {
                    val innerCounter = myCounter.innerCounter
                    val newValue = innerCounter.value + 1
                    myCounter =
                        myCounter.copy(innerCounter = myCounter.innerCounter.copy(value = newValue))
                }
            ) {
                Text("Update MyCounter")
    
            }
            Text("Value: ${myCounter.value}")
        }
    }