kotlinandroid-jetpack-composeandroid-jetpack-compose-lazy-column

Why does LazyColumn with inner .animateItem() not animate the LazyColumn height properly on item changes when not full height?


I want the LazyColumn height to be exactly the same as the items while still animating them via .animateItem(). With the code below, it looks nice but it uses .fillMaxHeight() which doesn't fit my use-case.

animates good with full height

Removing .fillMaxHeight() from the LazyColumn results in those janky height changes. I wonder if there is a fix to this?

not nice animation

It is a bit hard to see in the video and is best seen at 60 FPS, but basically, the issue is here that the height is not animated smoothly and the last item is cut off during the animation. I would always expect all items to be visible and not cut off, even partially.

Here "a" is being hidden. "c" is invisible

height not filling all

Here "b" is being hidden. "c" is invisible.

height not filling second

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.singleWindowApplication
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() = singleWindowApplication {
    data class Item(val text: String, val id: Int)

    var list by remember { mutableStateOf(listOf(Item("a", 1), Item("b", 2), Item("c", 3))) }
    Box(modifier = Modifier.fillMaxSize()) {
        Row(
            modifier = Modifier
                .background(Color.DarkGray)
                .fillMaxSize()
                .clickable {
                    println("clicked")
                }
        ) {
        }
        Row(
            horizontalArrangement = Arrangement.End, modifier = Modifier
                .fillMaxWidth()
        ) {
            LazyColumn(
                modifier = Modifier
                    .background(Color.LightGray)
                    .fillMaxHeight()
                    .width(300.dp)
            ) {
                items(list, { it.id }) {
                    Column(modifier = Modifier.animateItem()) {
                        Text(it.text,
                            color = Color.White,
                            modifier = Modifier
                                .padding(10.dp)
                                .fillMaxWidth()
                                .background(Color.Red)
                                .padding(10.dp)
                                .clickable {
                                    println("clicked text")
                                })
                    }
                }
            }
        }
    }
    LaunchedEffect(Unit) {
        launch {
            while (true) {
                delay(1000)
                list = list.toMutableList().drop(1)
                if (list.isEmpty()) {
                    delay(1000)
                    list = listOf(Item("a", 1), Item("b", 2), Item("c", 3))
                }
            }
        }
    }
}

Solution

  • This happens because the layout bounds immediately are updated to match the fewer items, while the animation is not even finished yet. Unfortuntately the Android Team said they are not going to fix this:

    The issue here is that the list is using "wrap content" height. Also lists are clipping its content as any scrollable containers. When you remove some item, then we immediately have less items to display so the height needed to wrap the content became smaller. It results in the last item being clipped. It would be nice for us to automatically make this case to look nicer, but it might be unexpected behaviour in some use cases. For now we recommend to not use wrap content in such cases. Just applying Modifier.fillMaxHeight() on LazyColumn will resolve that.

    The issue also is marked as Won't fix (Intended behavior).

    I can't think of a clean way to fix this, however as a workaround you can add one invisible extra item at the very end of the list and see if that is acceptable in your case:

    LazyColumn(
        modifier = Modifier
            .background(Color.LightGray)
            .fillMaxHeight()
            .width(300.dp)
    ) {
        items(list, { it.id }) {
            //...
        }
        item {
            Box(modifier = Modifier.size(32.dp))  // set height as needed
        }
    }