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?
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
@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}")
}
}