12、滚动组件:ScrollState、LazyListState 的使用、监听滚动位置
滚动,是移动端最基础也最频繁的交互之一。你想想看,用户每天在屏幕上划来划去,本质上就是在跟滚动状态打交道。Compose 里处理滚动,核心就是两个东西:ScrollState 和 LazyListState。今天我们就把它彻底讲透。
一、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:列表滚动的进阶玩法
到了 LazyColumn 和 LazyRow,情况就不一样了。它们用的是 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 左右,太快了晃眼,太慢了着急。
五、知识体系总览
下面这张图概括了本章的核心逻辑:
六、几个实用技巧
最后分享几个我在项目中积累的小经验:
- 滚动到指定位置:用
listState.scrollToItem()做瞬间跳转,用animateScrollToItem()做平滑滚动。前者适合「加载后自动定位」,后者适合用户主动触发的操作。 - 监听滚动方向:通过比较前后两次的
firstVisibleItemIndex或value来判断。我做过一个「上滑隐藏标题栏,下滑显示」的效果,就是靠这个实现的。 - 性能注意:不要在
LaunchedEffect里频繁 collect 滚动状态去做复杂计算。如果只是 UI 层变化,derivedStateOf是更好的选择。
一个小技巧:如果你需要知道当前可见的所有 item 索引,可以用 listState.layoutInfo.visibleItemsInfo。它返回一个列表,每个元素包含 index、offset、size 等信息。这在做「曝光埋点」时特别有用。
好了,滚动组件的核心内容就这些。说白了,ScrollState 和 LazyListState 就是 Compose 滚动世界的两把钥匙——一个管普通滚动,一个管懒加载列表。掌握了它们,你就能随心所欲地监听和控制滚动行为。
公众号:蓝海资料掘金营,微信deep3321