12、滚动组件:ScrollState、LazyListState 的使用、监听滚动位置

滚动,是移动端最基础也最频繁的交互之一。你想想看,用户每天在屏幕上划来划去,本质上就是在跟滚动状态打交道。Compose 里处理滚动,核心就是两个东西:ScrollStateLazyListState。今天我们就把它彻底讲透。

一、ScrollState:普通滚动的基石

先说说 ScrollState。它对应的是 Modifier.verticalScroll() 这种普通滚动场景——内容一次性全部渲染,只是通过滚动来查看不同区域。

我记得刚用 Compose 那会儿,以为滚动就是加个修饰符完事。后来发现,要监听滚动位置、做联动效果,必须拿到 ScrollState 实例。

val scrollState = rememberScrollState()
Column(
    modifier = Modifier
        .verticalScroll(scrollState)
        .fillMaxSize()
) {
    // 内容...
}

rememberScrollState() 内部创建了一个 ScrollState,默认初始偏移量是 0。它暴露了几个关键属性:

属性 类型 说明
value Int 当前滚动偏移量(像素)
maxValue Int 最大可滚动偏移量
isScrollInProgress Boolean 是否正在滚动中

核心要点:value 是实时变化的,你可以用它做任何跟位置相关的逻辑——比如显示/隐藏悬浮按钮、改变标题栏透明度等。

二、监听滚动位置:三种常用方式

实际项目中,监听滚动位置的需求太常见了。我做过一个电商 App,商品详情页的顶部图片会随着滚动逐渐模糊——这就是典型的滚动监听场景。

方式一:直接读取 scrollState.value

val scrollState = rememberScrollState()
val currentOffset = scrollState.value

// 在 Composable 中直接使用
if (currentOffset > 200) {
    // 显示返回顶部按钮
}

方式二:使用 snapshotFlow 转为冷流

LaunchedEffect(scrollState) {
    snapshotFlow { scrollState.value }
        .collect { offset ->
            Log.d("Scroll", "当前偏移量: $offset")
        }
}

方式三:结合 derivedStateOf 做高效计算

val isOverThreshold by remember {
    derivedStateOf { scrollState.value > 500 }
}

我的习惯:如果只是 UI 层判断,用 derivedStateOf 最省心。它只在依赖变化时才重新计算,性能开销极小。

三、LazyListState:列表滚动的进阶玩法

到了 LazyColumnLazyRow,情况就不一样了。它们用的是 LazyListState,因为列表项是懒加载的,你没法直接用像素偏移量来定位。

val listState = rememberLazyListState()
LazyColumn(
    state = listState,
    modifier = Modifier.fillMaxSize()
) {
    items(100) { index ->
        Text("Item $index")
    }
}

LazyListState 的核心属性:

属性 类型 说明
firstVisibleItemIndex Int 当前第一个可见项的索引
firstVisibleItemScrollOffset Int 第一个可见项的偏移量
layoutInfo LazyListLayoutInfo 包含所有可见项的信息

嗯,这里要注意:firstVisibleItemIndex 是从 0 开始的。我曾经踩过一个坑——用它做「是否显示回到顶部按钮」的判断时,忘了考虑偏移量,结果按钮在列表刚滚动一点点就出现了。

避坑指南:判断是否在顶部,应该同时检查 firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0。只检查索引是不够的。

四、实战:监听滚动实现悬浮按钮

光说不练假把式。我们写一个完整的例子:列表滚动超过一屏时,显示「回到顶部」的悬浮按钮。

@Composable
fun ScrollToTopExample() {
    val listState = rememberLazyListState()

    // 判断是否显示按钮
    val showButton by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 0 ||
            listState.firstVisibleItemScrollOffset > 0
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        LazyColumn(
            state = listState,
            modifier = Modifier.fillMaxSize()
        ) {
            items(100) { index ->
                ListItem(index = index)
            }
        }

        // 悬浮按钮
        AnimatedVisibility(
            visible = showButton,
            enter = fadeIn() + scaleIn(),
            exit = fadeOut() + scaleOut(),
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp)
        ) {
            FloatingActionButton(
                onClick = {
                    // 平滑滚动到顶部
                    coroutineScope.launch {
                        listState.animateScrollToItem(0)
                    }
                }
            ) {
                Icon(Icons.Default.ArrowUpward, contentDescription = "回到顶部")
            }
        }
    }
}

这里用 animateScrollToItem() 来做平滑滚动,用户体验比直接跳转好得多。我个人习惯把滚动动画时长控制在 300ms 左右,太快了晃眼,太慢了着急。

五、知识体系总览

下面这张图概括了本章的核心逻辑:

Compose 滚动体系 普通滚动 Modifier.verticalScroll() ScrollState value / maxValue / isScrollInProgress 懒加载列表 LazyColumn / LazyRow LazyListState firstVisibleItemIndex / layoutInfo 监听方式:snapshotFlow / derivedStateOf / 直接读取

六、几个实用技巧

最后分享几个我在项目中积累的小经验:

  • 滚动到指定位置:listState.scrollToItem() 做瞬间跳转,用 animateScrollToItem() 做平滑滚动。前者适合「加载后自动定位」,后者适合用户主动触发的操作。
  • 监听滚动方向:通过比较前后两次的 firstVisibleItemIndexvalue 来判断。我做过一个「上滑隐藏标题栏,下滑显示」的效果,就是靠这个实现的。
  • 性能注意:不要在 LaunchedEffect 里频繁 collect 滚动状态去做复杂计算。如果只是 UI 层变化,derivedStateOf 是更好的选择。

一个小技巧:如果你需要知道当前可见的所有 item 索引,可以用 listState.layoutInfo.visibleItemsInfo。它返回一个列表,每个元素包含 index、offset、size 等信息。这在做「曝光埋点」时特别有用。

好了,滚动组件的核心内容就这些。说白了,ScrollStateLazyListState 就是 Compose 滚动世界的两把钥匙——一个管普通滚动,一个管懒加载列表。掌握了它们,你就能随心所欲地监听和控制滚动行为。


公众号:蓝海资料掘金营,微信deep3321