Search code examples
androidkotlinandroid-jetpack-composedata-classcompose-recomposition

Jetpack compose mutableStateOf list doesn't trigger re-composition when changing property value in list item class


I think I'm missing a core concept of Jetpack Compose here. I'm running into an issue when I'm trying to change a non-constructor data class property inside of a composable when this composable is part of an observed list.

Does not work: (sadProperty is not declared in the constructor)

data class IntWrapper(val actualInt: Int = 0) {
var sadProperty: Int = 0
}

@Preview
@Composable
fun test() {
var state by remember { mutableStateOf(listOf(IntWrapper(1), IntWrapper(2), IntWrapper(3),IntWrapper(4)))}

    fun onClick(item: IntWrapper) {
        val indexOf = state.indexOf(item)
        val newState = state.minus(item).toMutableList()
        val copy = item.copy()
        copy.sadProperty = Random.nextInt()
        newState.add(indexOf, copy)
        state = newState
    }
    
    Column() {
        for (item in state) {
            Text("ac: ${item.actualInt} sad: ${item.sadProperty}", modifier = Modifier.clickable { onClick(item)})
        }
    }

}

Works: (actualInt is declared in the constructor)

data class IntWrapper(var actualInt: Int = 0) {
var sadProperty: Int = 0
}

@Preview
@Composable
fun test() {
var state by remember { mutableStateOf(listOf(IntWrapper(1), IntWrapper(2), IntWrapper(3),IntWrapper(4)))}

    fun onClick(item: IntWrapper) {
        val indexOf = state.indexOf(item)
        val newState = state.minus(item).toMutableList()
        val copy = item.copy()
        copy.actualInt = Random.nextInt()
        newState.add(indexOf, copy)
        state = newState
    }
    
    Column() {
        for (item in state) {
            Text("ac: ${item.actualInt} sad: ${item.sadProperty}", modifier = Modifier.clickable { onClick(item)})
        }
    }

}

Could somebody explain why this happens?


Solution

  • This looks like a question of both Jetpack Compose and about Kotlin data class, bare with me, I'll try my best.

    Lets start with Kotlin's Data classes first

    As per the kotlin docs about Data Class

    The compiler automatically derives the following members from all properties declared in the primary constructor:

    • equals()/hashCode() pair
    • toString() of the form "User(name=John, age=42)"
    • componentN() functions corresponding to the properties in their order of declaration.
    • copy() .

    Your IntWrapper data class has one Primary Constructor, the parenthesis that follows the class name, and with 1 property declared inside of it.

    data class IntWrapper(val actualInt: Int = 0) {
          var sadProperty: Int = 0
    }
    

    with that, we can say, your IntWrapper data class has

    • 1 component (actualInt)
    • toString() of the form IntWrapper(actualInt=?)
    • a generated copy() function
    • a generated equals()/hashCode() pair

    and based again from the docs:

    The compiler only uses the properties defined inside the primary constructor for the automatically generated functions. To exclude a property from the generated implementations, declare it inside the class body:

    The equals will only use/evaluate the property declared from IntWrapper's primary constructor (i.e actualInt : Int), and sadProperty is excluded from it because its in the part of the data class body.

    Now consider the following:

    val intWrapper1 = IntWrapper(actualInt = 5)
    intWrapper1.sadProperty = 5
    
    val intWrapper2 = IntWrapper(actualInt = 5)
    intWrapper2.sadProperty = 10
    
    Log.e("AreTheyEqual?", "${intWrapper1 == intWrapper2}")
    

    it prints,

    E/AreTheyEqual?: true
    

    because the equality sees both of the derived properties have the same value 5, sadProperty is excluded from this comparison.

    val intWrapper1 = IntWrapper(actualInt = 5)
    intWrapper1.sadProperty = 5
    
    val intWrapper2 = IntWrapper(actualInt = 10)
    intWrapper2.sadProperty = 5
    

    prints,

    E/AreTheyEqual?: false
    

    because the generated equals verifies that the generated component (actualInt) is NOT the same from the two IntWrapper instance.

    Now going to Jetpack Compose, applying everything we understand with data classes,

    • The first test complies with everything about data class, it creates a new object with a new value and that's what Compose needs to trigger re-composition.

    • The second test will not trigger re-composition, Compose still sees the same IntWrapper instance because sadProperty is not part of the generated components that will be used by the data class's equals operation.