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 内部的布局越简单越好。嵌套太多 ColumnRow 会影响测量性能。
  • 使用 immutable 数据:尽量用 data class 或不可变集合,Compose 能更快地检测变化。
  • 延迟加载图片:图片用 Coil 或 Glide 的 Compose 版本,它们会自动处理缓存和异步加载。
  • 限制 item 数量:如果数据量特别大(比如上万条),考虑在 ViewModel 里做分页,不要一次性全部加载。

注意:不要在 LazyColumn 的 item 里使用 Modifier.weight。weight 需要父布局是 RowColumn,而 LazyColumn 的 item 是直接放在 LazyListScope 里的,没有父布局约束。用了 weight 会导致布局异常。

10.8 本章小结

列表和网格是 Compose 开发中的基本功。LazyColumn 的 key 和 stickyHeader、LazyGrid 的列数控制、StaggeredGrid 的瀑布流效果,这些都是日常开发中高频使用的功能。下拉刷新和加载更多更是每个 App 的标配。

我个人建议,在实际项目中,把列表相关的逻辑封装成一个通用的 Composable 组件,比如 RefreshablePaginatedList。这样团队里所有人都能用同一套方案,减少重复劳动,也方便后期维护。

好了,这一章的内容就到这里。代码示例我都测试过,可以直接复制到项目里用。如果你在实现过程中遇到问题,欢迎随时交流。


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