10、列表与网格:LazyColumn高级用法、LazyGrid、StaggeredGrid、下拉刷新与加载更多
列表和网格,是移动应用里最常用的界面元素。说实话,几乎每个App都离不开它们。Jetpack Compose 提供了 LazyColumn、LazyGrid 和 StaggeredGrid 来高效处理大量数据。今天我们就来聊聊这些组件的进阶用法,以及如何实现下拉刷新和加载更多。
核心知识点:LazyColumn 的 item 复用机制、LazyVerticalGrid 的网格布局、StaggeredGrid 的瀑布流效果、下拉刷新的标准实现、分页加载的常见模式。
10.1 LazyColumn 高级用法
LazyColumn 是 Compose 中最常用的列表组件。它只渲染屏幕上可见的 item,滚动时回收不可见的 item。这个机制和 RecyclerView 类似,但写法简洁得多。
我个人习惯把 LazyColumn 的 item 拆分成独立的 Composable 函数,这样代码更清晰。比如这样:
@Composable
fun UserList(users: List<User>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(users, key = { it.id }) { user ->
UserItem(user = user)
}
}
}
这里有个关键点:key 参数。我在项目中遇到过一个问题——列表更新时 item 闪烁。后来发现是没加 key。加上 key 后,Compose 就能准确追踪每个 item 的变化,避免不必要的重组。
小技巧:LazyColumn 的 key 应该用稳定且唯一的标识符,比如数据库中的 ID。不要用 index,否则列表增删时会导致动画异常。
LazyColumn 还支持 stickyHeader,也就是粘性头部。比如通讯录里的字母索引:
LazyColumn {
stickyHeader {
Text("A", fontWeight = FontWeight.Bold)
}
items(users.filter { it.name.startsWith("A") }) { user ->
UserItem(user)
}
stickyHeader {
Text("B", fontWeight = FontWeight.Bold)
}
items(users.filter { it.name.startsWith("B") }) { user ->
UserItem(user)
}
}
嗯,这里要注意:stickyHeader 的 content 不会随列表滚动而消失,它会一直固定在顶部,直到下一个 stickyHeader 把它顶上去。
10.2 LazyGrid:网格布局
LazyVerticalGrid 和 LazyHorizontalGrid 用于展示网格数据。你可以指定列数或行数,Compose 会自动分配空间。
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
PhotoItem(photo = photo)
}
}
}
GridCells.Fixed(2) 表示固定两列。你也可以用 GridCells.Adaptive(minSize = 120.dp),它会根据屏幕宽度自动计算列数,保证每个 item 至少 120dp 宽。
我曾经在做一个图片浏览应用时,用了 Adaptive 模式。结果在平板上显示效果特别好,列数自动变多了,用户体验很流畅。
避坑指南:LazyGrid 的 item 高度如果不固定,可能会导致滚动卡顿。因为 Compose 需要不断测量 item 的高度来调整布局。尽量给 item 设置固定高度,或者用 Modifier.heightIn 限制范围。
10.3 StaggeredGrid:瀑布流效果
StaggeredGrid 是 Compose 1.2 引入的组件,专门用来做瀑布流布局。每个 item 的高度可以不同,像 Pinterest 那样错落有致。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StaggeredPhotoGrid(photos: List<Photo>) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
PhotoItem(photo = photo, modifier = Modifier.fillMaxWidth())
}
}
}
注意,StaggeredGrid 目前还是 Experimental API,需要加 @OptIn(ExperimentalFoundationApi::class) 注解。不过放心,这个 API 已经很稳定了,Google 官方也在积极维护。
为什么用 StaggeredGrid?说白了,就是让不同高度的图片都能完整显示,不留白边。普通网格会把所有 item 裁剪成相同高度,而瀑布流会保持原始比例。
10.4 下拉刷新
下拉刷新是移动应用的标配功能。Compose Material 3 提供了 pullToRefresh 修饰符,用起来很方便。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefreshableList(
items: List<String>,
onRefresh: () -> Unit,
isRefreshing: Boolean
) {
val pullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.fillMaxSize()
.pullToRefresh(
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = onRefresh
)
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items) { item ->
Text(text = item, modifier = Modifier.padding(16.dp))
}
}
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
这里有个细节:PullToRefreshDefaults.Indicator 是 Material 3 提供的默认刷新指示器。如果你想要自定义样式,可以自己写一个 Composable 替代它。
我曾经在项目中遇到一个问题:下拉刷新时列表会跳动。后来发现是 isRefreshing 状态更新不及时导致的。解决办法是在 ViewModel 里用 StateFlow 管理刷新状态,确保 UI 层能及时响应。
10.5 加载更多(分页)
加载更多,也叫分页加载或无限滚动。当用户滚动到列表底部时,自动加载下一页数据。
实现思路很简单:监听 LazyColumn 的滚动状态,当最后一个 item 可见时触发加载。
@Composable
fun PaginatedList(
items: List<String>,
hasMore: Boolean,
onLoadMore: () -> Unit
) {
val listState = rememberLazyListState()
// 检测是否滚动到底部
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null &&
lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 3 &&
hasMore
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
onLoadMore()
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(items) { item ->
Text(text = item, modifier = Modifier.padding(16.dp))
}
// 底部加载指示器
if (hasMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
注意,我用 derivedStateOf 来监听滚动位置,而不是直接在 LaunchedEffect 里读取 listState。这样做的好处是性能更好——derivedStateOf 只在依赖变化时重新计算,不会频繁触发重组。
经验之谈:加载更多时,最好在底部显示一个加载指示器,让用户知道正在加载。同时,如果网络慢,可以加一个「加载失败,点击重试」的提示。用户体验会好很多。
10.6 综合示例:带下拉刷新和加载更多的列表
把下拉刷新和加载更多结合起来,就是一个完整的列表方案。下面是一个简化版的实现:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RefreshablePaginatedList(
viewModel: MyViewModel
) {
val items by viewModel.items.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val hasMore by viewModel.hasMore.collectAsState()
val pullToRefreshState = rememberPullToRefreshState()
val listState = rememberLazyListState()
// 加载更多检测
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null &&
lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 3 &&
hasMore && !isRefreshing
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
viewModel.loadMore()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.pullToRefresh(
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = { viewModel.refresh() }
)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(items, key = { it.id }) { item ->
ListItem(item = item)
}
if (hasMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
这个示例里,我把下拉刷新和加载更多整合在了一起。注意,加载更多时我判断了 !isRefreshing,避免刷新和加载同时触发,造成数据混乱。
10.7 性能优化建议
列表性能优化是个老生常谈的话题。我总结了几条实用建议:
- 使用 key:给每个 item 设置稳定的 key,避免不必要的重组。
- 避免复杂布局:item 内部的布局越简单越好。嵌套太多
Column或Row会影响测量性能。 - 使用 immutable 数据:尽量用
data class或不可变集合,Compose 能更快地检测变化。 - 延迟加载图片:图片用 Coil 或 Glide 的 Compose 版本,它们会自动处理缓存和异步加载。
- 限制 item 数量:如果数据量特别大(比如上万条),考虑在 ViewModel 里做分页,不要一次性全部加载。
注意:不要在 LazyColumn 的 item 里使用 Modifier.weight。weight 需要父布局是 Row 或 Column,而 LazyColumn 的 item 是直接放在 LazyListScope 里的,没有父布局约束。用了 weight 会导致布局异常。
10.8 本章小结
列表和网格是 Compose 开发中的基本功。LazyColumn 的 key 和 stickyHeader、LazyGrid 的列数控制、StaggeredGrid 的瀑布流效果,这些都是日常开发中高频使用的功能。下拉刷新和加载更多更是每个 App 的标配。
我个人建议,在实际项目中,把列表相关的逻辑封装成一个通用的 Composable 组件,比如 RefreshablePaginatedList。这样团队里所有人都能用同一套方案,减少重复劳动,也方便后期维护。
好了,这一章的内容就到这里。代码示例我都测试过,可以直接复制到项目里用。如果你在实现过程中遇到问题,欢迎随时交流。
公众号:蓝海资料掘金营,微信 deep3321