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

Jetpack Compose:如何仅为LazyColumn顶部粘性头部添加滚动动画

实现仅顶部粘性头部的滚动动画效果

问题背景

想要实现用户滚动粘性头部所属分组列表项时,仅顶部的粘性头部添加动画效果,但当前代码上下滚动时所有粘性头部都会触发动画。尝试通过firstVisibleItemIndex获取第一个可见头部索引,但该值对应的是分组的第一个列表项而非头部,LazyListState本身不支持直接获取粘性头部索引。

解决方案

思路一:通过滚动偏移量计算顶部头部索引

适合头部和列表项高度固定的场景,通过预计算分组总高度,结合滚动偏移量定位当前顶部粘性头部:

1. 预计算分组总高度

val listState = rememberLazyListState()

// 转换固定高度为像素值
val headerHeight = with(LocalDensity.current) { 60.dp.toPx() }
val itemHeight = with(LocalDensity.current) { 48.dp.toPx() }

// 计算每个分组的总高度(头部+所有列表项)
val groupTotalHeights = remember(categories) {
    categories.map { headerHeight + it.items.size * itemHeight }
}

// 生成累计高度列表,用于快速匹配滚动位置
val cumulativeHeights = remember(groupTotalHeights) {
    mutableListOf<Float>().apply {
        var sum = 0f
        add(sum)
        groupTotalHeights.forEach {
            sum += it
            add(sum)
        }
    }
}

2. 计算当前顶部头部索引

val currentTopHeaderIndex = remember(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
    var totalOffset = 0f
    var currentGroupIndex = 0
    var accumulatedItemCount = categories[currentGroupIndex].items.size

    // 确定firstVisibleItemIndex所属的分组
    while (listState.firstVisibleItemIndex >= accumulatedItemCount) {
        totalOffset += groupTotalHeights[currentGroupIndex]
        currentGroupIndex++
        accumulatedItemCount += categories[currentGroupIndex].items.size
    }

    // 添加当前分组内的滚动偏移
    totalOffset += listState.firstVisibleItemScrollOffset

    // 匹配对应的顶部头部索引
    cumulativeHeights.indexOfLast { it <= totalOffset }
}

3. 绑定动画到顶部头部

val topHeaderScale = remember { mutableStateOf(1f) }

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            // 仅处理拖拽滚动,忽略惯性滚动
            if (source == NestedScrollSource.Drag) {
                topHeaderScale.value = when {
                    available.y < 0 -> max(0.8f, topHeaderScale.value - 0.01f) // 上滚缩小
                    available.y > 0 -> min(1f, topHeaderScale.value + 0.01f) // 下滚恢复
                    else -> topHeaderScale.value
                }
            }
            return Offset.Zero
        }
    }
}

// 构建LazyColumn
LazyColumn(
    state = listState,
    modifier = modifier.nestedScroll(nestedScrollConnection)
) {
    categories.forEachIndexed { index, category ->
        stickyHeader {
            SessionCard(
                scale = if (index == currentTopHeaderIndex) topHeaderScale.value else 1f,
                index = index
            )
        }
        items(category.items) { text ->
            CategoryItem(text)
        }
    }
}

思路二:利用布局可见性回调判断顶部头部

适合高度不固定的场景,通过onGloballyPositioned实时检测头部位置:

val listState = rememberLazyListState()
val topHeaderIndex = remember { mutableStateOf(0) }
val topHeaderScale = remember { mutableStateOf(1f) }

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if (source == NestedScrollSource.Drag) {
                topHeaderScale.value = when {
                    available.y < 0 -> max(0.8f, topHeaderScale.value - 0.01f)
                    available.y > 0 -> min(1f, topHeaderScale.value + 0.01f)
                    else -> topHeaderScale.value
                }
            }
            return Offset.Zero
        }
    }
}

LazyColumn(
    state = listState,
    modifier = modifier.nestedScroll(nestedScrollConnection)
) {
    categories.forEachIndexed { index, category ->
        stickyHeader {
            SessionCard(
                scale = if (index == topHeaderIndex.value) topHeaderScale.value else 1f,
                index = index
            ).apply {
                modifier.onGloballyPositioned { coordinates ->
                    val viewportTop = listState.layoutInfo.viewportStartOffset
                    val headerTop = coordinates.positionInParent().y
                    val headerBottom = headerTop + coordinates.size.height

                    // 判断当前头部是否处于视口顶部(粘性头部的特征位置)
                    if (headerTop <= viewportTop && headerBottom >= viewportTop) {
                        topHeaderIndex.value = index
                    }
                }
            }
        }
        items(category.items) { text ->
            CategoryItem(text)
        }
    }
}

优化建议

  • 如果需要更平滑的动画,建议使用Animatable替代直接修改scale值,例如:
    val scaleAnimatable = remember { Animatable(1f) }
    // 在滚动回调中使用scaleAnimatable.animateTo(...)
    
  • 避免在滚动回调中执行复杂计算,尽量用remember和derivedStateOf缓存计算结果,提升性能

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.28 12:06:00