androidandroid-jetpack-composelazycolumnjetpack-compose-animation

Expand and collapse LazyColumn items with animation in Jetpack Compose


I am using stickyHeader with LazyColumn, and I want to expand and collapse the items under each header when the header is clicked, with smooth animations. I've implemented this behavior using AnimatedVisibility, which works fine for a small number of items.

However, when the number of items under a header becomes large (e.g., 100+ items), the UI starts to lag significantly during the animation.

Is there a better way to achieve this kind of expanding and collapsing behavior with better performance, or are there any optimizations I can make to AnimatedVisibility?

Any advice or alternative approaches are appreciated!

Current implementation:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimatedImageWithTextItem(groupedItems: Map<String, List<Document>>) {
    val state = remember { mutableStateMapOf<String, Boolean>() }
    LazyColumn(
        modifier = Modifier
            .fillMaxSize().
    ) {

        groupedItems.onEachIndexed { groupIndex, (key, elements) ->
            val isExpanded = state[key] ?: true
            stickyHeader {
                Row(modifier = Modifier
                    .fillMaxWidth()
                    .height(50.dp)
                    .background(Color.Yellow)
                    .clickable { state[key] = !isExpanded })
                {
                    Text("header $key")
                }
            }

            itemsIndexed(
                items = elements,
                key = { _, item -> item.id }) { elementIndex, element ->

                ExpandableContent(isExpanded) {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(50.dp)
                            .background(Color.White))
                    {
                        Text("Item of the header")
                    }

                }
            }


        }
    }
}

const val EXPANSTION_TRANSITION_DURATION = 300
@Composable
fun ExpandableContent(
    visible: Boolean = true,
    content: @Composable () -> Unit
) {
    val enterTransition = remember {
        expandVertically(
            expandFrom = Alignment.Top,
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        ) + fadeIn(
            initialAlpha = 0.3f,
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        )
    }
    val exitTransition = remember {
        shrinkVertically(
            // Expand from the top.
            shrinkTowards = Alignment.Top,
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        ) + fadeOut(
            // Fade in with the initial alpha of 0.3f.
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        )
    }

    AnimatedVisibility(
        visible = visible,
        enter = enterTransition,
        exit = exitTransition
    ) {
        content()
    }
}

Document Class:

data class Document(
    val title: String?,
    val date: String,
    val text: String?,
    val id: Int,
)

Here is how I generate a dummy data list:

fun generateDummyDocuments(numberOfDocuments: Int): List<Document> {
    val documents = mutableListOf<Document>()
    for (i in 1..numberOfDocuments) {
        val title = "Document Title $i"
        val date = "2024-09-01"
        val text = "This is the content of document $i. It's a dummy text."
        val id = i

        val document = Document(title, date, text, id)
        documents.add(document)
    }
    return documents
}

Here is activity class:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ComposetestTheme {
                var documents by remember {
                    mutableStateOf<Map<String, List<Document>>>(
                        emptyMap()
                    )
                }

                LaunchedEffect(Unit) {
                    documents = generateDummyDocuments(100).groupBy { it.date }

                }

                AnimatedImageWithTextItem(documents)
            }
        }
    }
}

enter image description here


Solution

  • You call val groupedItems = list.groupBy { it.date } in every recomposition. This might be the problem.

    https://developer.android.com/develop/ui/compose/performance/bestpractices#use-remember

    Composable functions can run very frequently, as often as for every frame of an animation. For this reason, you should do as little calculation in the body of your composable as you can.

    An important technique is to store the results of calculations with remember. That way, the calculation runs once, and you can fetch the results whenever they're needed.

    For example, here's some code that displays a sorted list of names, but does the sorting in a very expensive way:

    @Composable
    fun ContactList(
        contacts: List<Contact>,
        comparator: Comparator<Contact>,
        modifier: Modifier = Modifier
    ) {
        LazyColumn(modifier) {
            // DON’T DO THIS
            items(contacts.sortedWith(comparator)) { contact ->
                // ...
            }
        }
    }
    

    Edit

    After testing the code provided by OP i see that on expansion every item is being recomposed. That's the issue. The moment visible is set every item in list enters composition and number of compositions effect performance depending o device specs. In emulator i had no significant drop as in gif below but on my device performance hit was significant.

    And none of the performance tips above would solve this alone, because items are entering composition while LazyColumn should only have the ones that can fit to viewport. Without AnimatedVisibility issue still exists, i tested with Column as in code below as well.

    I used this code to test with SideEffect to display recompositions.

    data class Document(
        val title: String?,
        val date: String,
        val text: String?,
        val id: String,
    )
    
    fun generateDummyDocuments(numberOfDocuments: Int, key: String): List<Document> {
        val documents = mutableListOf<Document>()
        for (i in 1..numberOfDocuments) {
            val title = "Document Title $i"
            val date = "2024-09-$key"
            val text = "This is the content of document $i. It's a dummy text."
            val id = "$key$i"
    
            val document = Document(title, date, text, id)
            documents.add(document)
        }
        return documents
    }
    
    @Preview
    @Composable
    fun AnimateTest() {
        var documents by remember {
            mutableStateOf<Map<String, List<Document>>>(
                emptyMap()
            )
        }
    
        LaunchedEffect(Unit) {
            val list1 = generateDummyDocuments(100, "01")
            val list2 = generateDummyDocuments(100, "02")
            val list3 = generateDummyDocuments(100, "03")
            val list4 = generateDummyDocuments(100, "04")
            val list5 = generateDummyDocuments(100, "05")
    
            documents = mutableStateListOf<Document>().apply {
                addAll(list1)
                addAll(list2)
                addAll(list3)
                addAll(list4)
                addAll(list5)
            }.groupBy { it.date }
        }
    
        AnimatedImageWithTextItem(documents)
    }
    
    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun AnimatedImageWithTextItem(groupedItems: Map<String, List<Document>>) {
        val state = remember { mutableStateMapOf<String, Boolean>() }
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
        ) {
    
            groupedItems.onEachIndexed { groupIndex, (key, elements) ->
                val isExpanded = state[key] ?: true
                stickyHeader {
                    Row(modifier = Modifier
                        .fillMaxWidth()
                        .height(50.dp)
                        .background(Color.Yellow)
                        .clickable { state[key] = !isExpanded })
                    {
                        Text("header $key")
                    }
                }
    
    
                itemsIndexed(
                    items = elements,
                    key = { _, item -> item.id }) { elementIndex, element ->
    
                    ExpandableContent(
                        visible = { isExpanded }
                    ) {
    
                        SideEffect {
                            println("ElementIndex composing: $elementIndex")
                        }
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(50.dp)
                                .background(Color.White)
                        )
                        {
                            Text("Item of the header")
                        }
    
                    }
                }
    
    
            }
        }
    }
    
    const val EXPANSTION_TRANSITION_DURATION = 300
    
    @Composable
    fun ExpandableContent(
        visible: () -> Boolean = { true },
        content: @Composable () -> Unit,
    ) {
    
        Column {
            if (visible.invoke()) {
                content()
            }
        }
    
    //    val enterTransition = remember {
    //        expandVertically(
    //            expandFrom = Alignment.Top,
    //            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
    //        ) + fadeIn(
    //            initialAlpha = 0.3f,
    //            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
    //        )
    //    }
    //    val exitTransition = remember {
    //        shrinkVertically(
    //            // Expand from the top.
    //            shrinkTowards = Alignment.Top,
    //            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
    //        ) + fadeOut(
    //            // Fade in with the initial alpha of 0.3f.
    //            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
    //        )
    //    }
    //
    //    AnimatedVisibility(
    //        visible = visible.invoke(),
    //        enter = enterTransition,
    //        exit = exitTransition
    //    ) {
    //        content()
    //    }
    }
    

    enter image description here

    How to compose fixed number of items on expanding section

    For this you can add a previous and current state of visibility of that section and first time only set true for 20 items for instance and after scroll you can set.

    I used a expansion status with 3 states for this.

    enum class ExpansionStatus {
        Collapsed, ParentExpanded, Visible
    }
    

    When you click to button it changes parent from expanded status and with index i only compose 20 items, then when scroll happens i set status to Visible

    @Composable
    fun ExpandableContent(
        index: Int,
        visible: () -> ExpansionStatus = { ExpansionStatus.Visible },
        content: @Composable () -> Unit,
    ) {
    
        val enterTransition = remember {
            expandVertically(
                expandFrom = Alignment.Top,
                animationSpec = tween(
                    durationMillis = EXPANSTION_TRANSITION_DURATION
                )
            ) + fadeIn(
                initialAlpha = 0.3f,
                animationSpec = tween(
                    durationMillis = EXPANSTION_TRANSITION_DURATION
                )
            )
        }
        val exitTransition = remember {
            shrinkVertically(
                // Expand from the top.
                shrinkTowards = Alignment.Top,
                animationSpec = tween(
                    durationMillis = EXPANSTION_TRANSITION_DURATION
                )
            ) + fadeOut(
                // Fade in with the initial alpha of 0.3f.
                animationSpec = tween(
                    durationMillis = EXPANSTION_TRANSITION_DURATION
                )
            )
        }
    
        val status = visible.invoke()
        val isVisible =
            status == ExpansionStatus.Visible || (status == ExpansionStatus.ParentExpanded && index < 20)
    
        AnimatedVisibility(
            visible = isVisible,
            enter = enterTransition,
            exit = exitTransition
        ) {
            content()
        }
    }
    

    And usage

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun AnimatedImageWithTextItem(groupedItems: Map<String, List<Document>>) {
        val state = remember { mutableStateMapOf<String, ExpansionStatus>() }
        val lazyListState = rememberLazyListState()
    
        LaunchedEffect(Unit) {
            snapshotFlow { lazyListState.isScrollInProgress }
                .collect {
                    if (it) {
                        state.forEach { (key, expansionStatus) ->
                            if (expansionStatus == ExpansionStatus.ParentExpanded) {
                                state[key] = ExpansionStatus.Visible
                            }
                        }
                    }
                }
        }
    
        LazyColumn(
            modifier = Modifier
                .fillMaxSize(),
            state = lazyListState
        ) {
    
            groupedItems.onEachIndexed { groupIndex, (key, elements) ->
                val isExpanded = state[key] ?: ExpansionStatus.Visible
    
                stickyHeader {
                    Row(modifier = Modifier
                        .fillMaxWidth()
                        .height(50.dp)
                        .background(Color.Yellow)
                        .clickable {
                            val currentStatus = state[key]
    
                            if (currentStatus == ExpansionStatus.Collapsed) {
                                state[key] = ExpansionStatus.ParentExpanded
                            } else {
                                state[key] = ExpansionStatus.Collapsed
                            }
                        })
                    {
                        Text("header $key")
                    }
                }
    
    
                itemsIndexed(
                    items = elements,
                    key = { _, item -> item.id }) { elementIndex, element ->
    
                    ExpandableContent(
                        visible = {
                            isExpanded
                        },
                        index = elementIndex
                    ) {
    
                        SideEffect {
                            println("ElementIndex composing: $elementIndex")
                        }
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(50.dp)
                                .background(Color.White)
                        )
                        {
                            Text("Item of the header $elementIndex")
                        }
    
                    }
                }
            }
        }
    }