You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose中LazyColumn分组项展开收起动画优化求助

问题描述

在Jetpack Compose中使用stickyHeader搭配LazyColumn实现分组列表,点击头部带动画展开/收起分组下的列表项。当前通过AnimatedVisibility实现该功能,分组项数量较少时表现正常,但单分组项达到100+时,动画过程中UI出现明显卡顿。求性能更优的实现方式或AnimatedVisibility的优化方案。

当前实现代码

主Composable

@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(
            shrinkTowards = Alignment.Top,
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        ) + fadeOut(
            animationSpec = tween(EXPANSTION_TRANSITION_DURATION)
        )
    }

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

数据类与工具代码

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

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
}

Activity代码

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)
            }
        }
    }
}

优化方案

1. 单个动画容器替代多item独立动画

当前实现给每个列表项单独套AnimatedVisibility,100+项会同时触发上百个独立动画,导致UI线程计算过载。改为给整个分组的items包裹单个动画容器,让分组作为整体执行展开/收起动画。

修改后的核心代码:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimatedImageWithTextItem(groupedItems: Map<String, List<Document>>) {
    val state = remember { mutableStateMapOf<String, Boolean>() }
    LazyColumn(modifier = Modifier.fillMaxSize()) {
        groupedItems.forEach { (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")
                }
            }

            // 给整个分组包裹单个AnimatedVisibility
            item {
                AnimatedVisibility(
                    visible = isExpanded,
                    enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn(),
                    exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut()
                ) {
                    // 子LazyColumn承载分组内的所有项
                    LazyColumn(modifier = Modifier.fillMaxWidth()) {
                        itemsIndexed(elements, key = { _, item -> item.id }) { _, element ->
                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .height(50.dp)
                                    .background(Color.White)
                            ) {
                                Text("Item of the header: ${element.title}")
                            }
                        }
                    }
                }
            }
        }
    }
}

核心原理:将分组内的所有项放入子LazyColumn,通过单个AnimatedVisibility控制整体尺寸变化,动画仅计算一次容器的展开/收起,而非上百个独立项的动画。

2. 使用Spring动画替代Tween

tween是线性帧动画,需要每帧计算固定进度;spring基于物理模型,动画后期自动减速,减少UI线程计算压力,同时动画更自然流畅。

3. 移除不必要的动画叠加

如果不需要淡入淡出效果,可仅保留尺寸动画,进一步减少每帧绘制计算:

val enterTransition = remember {
    expandVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow))
}
val exitTransition = remember {
    shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessMediumLow))
}

4. 优化LazyColumn的项布局(可选)

若需保留单个item的动画效果(不推荐大量项场景),可给LazyColumn添加animateItemPlacement,让Compose自动优化项的布局动画:

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    animateItemPlacement(animationSpec = spring(stiffness = Spring.StiffnessLow))
) {
    // ... 原有代码结构
}

5. 减少不必要重组

  • 确保groupedItems使用稳定数据结构(如ImmutableMap),避免每次更新创建新对象;
  • 给stickyHeader和items设置稳定的key,减少Compose的重组范围。

内容的提问来源于stack exchange,提问作者Ali Asjad

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 05:05:53