androidkotlinandroid-jetpack-composeandroid-jetpacklazycolumn

State being lost when LazyColumn items move between item blocks


It seems like LazyColumn is losing state on rows when moved between different item calls even though they're keyed. It was my understanding that if two items existed with the same key the composable wouldn't need to recompose and therefore could run animations, remember state etc., but it doesn't seem to be the case here.

For example I have a LazyColumn backed by two items lists, where items move between the lists when clicked:

@Composable
fun TestScreen() {
    val items = remember { mutableStateListOf(Item(1, false), Item(2, false)) }

    val untoggledItems = items.filter { !it.toggled }
    val toggledItems = items.filter { it.toggled }

    fun onClickItem(id: Int) {
        for (i in items.indices) {
            val item = items[i]
            if (item.id == id) {
                items[i] = item.copy(toggled = !item.toggled)
            }
        }
    }

    LazyColumn {
        items(untoggledItems, { it.id }) { item ->
            ItemRow(modifier = Modifier.animateItem(), id = item.id, toggled = item.toggled, onClick = { onClickItem(item.id) })
        }

        items(toggledItems, { it.id }) { item ->
            ItemRow(modifier = Modifier.animateItem(), id = item.id, toggled = item.toggled, onClick = { onClickItem(item.id) })
        }
    }
}
private data class Item(val id: Int, val toggled: Boolean)
@Composable
private fun ItemRow(modifier: Modifier, id: Int, toggled: Boolean, onClick: () -> Unit) {
    val color by animateColorAsState(if (toggled) Color.Red else Color.Blue, tween(5_000))
    val randomNumber = remember { Random.nextDouble() }

    Button(onClick = onClick, modifier = modifier) {
        Text(text = "Item $id ($randomNumber)", color = color)
    }
}

In this case the moved item row is recomposed and both the animation and remembered value are lost. However, the items animations provided by Modifier.animateItem() do still run successfully which would rule out any keying issues.

When moved to a single list that sorted by the toggled state (which would yield the same layout) then state is remembered correctly.

I unfortunately couldn't find any documentation on this or even a preference to using a single list, in fact most official examples make multiple item calls. If anyone could provide any insight to this or a source where this is officially mentioned (either as a bug or expected behaviour) it would be much appreciated.


Note that my real use case is a lot larger than the MRE above, there are a few options which would workaround this issue, for example:

However, these are incredibly cumbersome and I would be surprised if this is the recommended usage.


Solution

  • This is unrelated to LazyColumn. All repeating structures are effected, even a simple for-loop. Furthermore, that behavior is intentional.

    Consider this code:

    Column {
        SomeItem(1)
        if (condition) SomeItem(2)
        if (!condition) SomeItem(3)
        SomeItem(4)
    }
    

    Now, this list will always contain exactly three SomeItems. In the code, however, there are four different calls to SomeItem, which is relevant for recompositions.

    On Recompositions a composable retains its remembered state. That is different when it leaves the composition, then all remembered state is lost. In the above code, if the condition toggles, one SomeItem leaves the composition and another enters, but they have entirely unrelated internal states: The leaving SomeItem does not transfer its state to the entering SomeItem, although the new one ends up at the exact same spot in the composition tree where the old one was.

    This doesn't change when you replace parts of the code with a loop:

    Column {
        for (i in listOf(1, 2)) {
            if (i != 2 || condition) SomeItem(i)
        }
    
        if (!condition) SomeItem(3)
        SomeItem(4)
    }
    

    This does exactly the same, it always displays three SomeItems, but when the condition toggles, the remembered state of the leaving item is lost and the new item starts with a fresh state.

    And this obviously also applies if you have two consecutive for loops:

    Column {
        for (i in listOf(1, 2)) {
            if (i != 2 || condition) SomeItem(i)
        }
    
        for (i in listOf(3, 4)) {
            if (i != 3 || !condition) SomeItem(i)
        }
    }
    

    It's still the same.


    Now, back to you original case with two lists in a LazyColumn: items internally calls for to iterate over the given list. So your case is actually the same as my last example above: You have one for loop of composables where you remove one item, and another for loop of composables that happens to gain an item. But the leaving composable from the first loop is entirely unrelated to the entering composable from the second loop. That's the reason why the ItemRow's color and randomNumber states are not retained.

    All of that changes when you join the two lists so you only have a single for loop. In that case you'll only change the iteration order of the list, but the composables will always be the same. So instead of one leaving and another entering the composition, the composables are simply recomposed, retaining their remembered state.

    The key that you specify for items does not - and cannot - affect this behavior. The key is only used to determine the item order which is needed to retain the scroll position when the list changes. It is also the reason why animateItem() works1. Specifying a key does not prevent one composable leaving and another one entering the composition, so it doesn't help.

    If you want to retain state when moving an item from one list to another you have to make sure the same composable is reused. That can only happen in the same for loop, not in two independent loops.

    Despite your reservations I would recommend you merge your various lists into a single list and loop over that, if you want to move items from one list to another and want their composables to retain their state. That only makes sense if you actually call the same type of composables for both lists. If you have different composables per list, then I would recommend hoisting the animation out of the composables instead.


    1 Removing the key will let the LazyColumn identify items by their index in the list, and in your case that wouldn't change, so no animation would be displayed. Please also note that animateItem - although applied to ItemRow - is hoisted out into the items lambda and is not part of the ItemRow's state. That's the reason why that state is not lost when you use a key. You already identified hoisting as a possible solution for your color animation as well.