以前用 RecyclerView 写网格布局,一套 Adapter、一个 GridLayoutManager、一个 ViewHolder,都快成肌肉记忆了。后来切到 Jetpack Compose,第一次用 LazyVerticalGrid 的时候,我最大的感受是:这玩意儿简直就像照着 LazyColumn 的用法做出来的孪生兄弟,声明式写法让网格布局变得直白很多。这篇文章我就围绕 LazyVerticalGrid 这个 Composable 组件,把它的设计思路、关键参数、真实可跑的示例代码,以及我在项目里踩过的坑一次讲清楚。
如果你正在把老项目从 XML 和 RecyclerView 往 Compose 迁移,或者刚学 Compose 没多久,想搞明白网格列表到底怎么写得优雅,这篇文章应该能帮你省下不少试错时间。我会按“为什么用、怎么选型、怎么写、怎么排坑”这个顺序来聊,没有废话,全是实操向的内容。
1. LazyVerticalGrid 的核心设计思路:为什么网格布局非它不可
1.1 从 RecyclerView 时代看网格布局的痛点迁移
以前在 XML 体系里做网格列表,流程很固定:先给 RecyclerView 设置 GridLayoutManager,再写 Adapter,创建 ViewHolder,处理 item 点击,还得手动处理加载更多的滚动监听。这套流程本身没啥问题,但代码量不小,而且 UI 和数据解耦得很“别扭”。尤其是当你需要“第一个 item 占整行”“某些 item 跨多列”这种特殊布局时,GridLayoutManager 要写 SpanSizeLookup,还要小心 index 对应关系,很容易出 bug。
到了 Compose 时代,LazyVerticalGrid 把这些全抽象掉了。它属于 androidx.compose.foundation.lazy 包里的 Lazy 系列组件,核心特点是:数据驱动、按需组合、滚动复用。你不用再写 Adapter,不用手动管 ViewHolder,只需要在 content 里用 GridItemSpan 之类的参数来描述每个 item 的展示规则。数据量不管是一百条还是十万条,它都能只组合当前可见区域那几项。这一点跟 LazyColumn 的思路完全一致,学习成本很低。
提示:LazyVerticalGrid 和 LazyColumn 一样,都是 Lazy 组件,接收一个 LazyGridScope 作用域。在这个作用域里,你能调用的 items / item / spans 相关 API 和 LazyListScope 高度相似,所以之前会写 LazyColumn 的人,上手 LazyVerticalGrid 几乎是零门槛。
1.2 从“列数”维度重新理解网格
网格布局和普通列表最大的区别,在于“列数”的确定方式。用 RecyclerView 时,列数是 GridLayoutManager 构造参数里的一个固定 int,比如 GridLayoutManager(this, 3)。这 3 列在横竖屏切换、不同尺寸平板上都不变,你只能手动监听配置变化再去改列数。
LazyVerticalGrid 的做法是直接抽象出一个 GridCells 类型,提供了三种创建方式:GridCells.Fixed、GridCells.Adaptive、GridCells.FixedSize。这三种方式对应的适用场景差别很大,我后面会单独用一节来拆。
但这里先强调一个宏观层面的变化:LazyVerticalGrid 把“列数”从“写死”变成了“策略化”。你不再需要自己去判断屏幕宽度,再去计算多少列,而是告诉 Compose:“我想固定三列”或者“我每个 item 最小要 100.dp”,剩下的交给组件自己去算。这种思路在响应式适配里特别香,尤其做那些要同时适配手机和平板的页面,能少写一坨 if 判断。
1.3 三种单元格尺寸策略,到底该用哪种
我用实际场景来对比GridCells.Fixed、GridCells.Adaptive、GridCells.FixedSize这三种方式的差异:
| 策略 | 写法 | 表现 | 适合场景 |
|---|---|---|---|
| GridCells.Fixed(3) | 固定 3 列 | 不管屏幕多宽,永远 3 列 | 宫格入口、设置选项、功能菜单 |
| GridCells.Adaptive(120.dp) | 每列最小 120.dp,自动算列数 | 屏幕越宽,列数越多 | 商品列表、图片瀑布流、自适配网格 |
| GridCells.FixedSize(120.dp) | 每列固定 120.dp 宽 | 多出来的空间留白,不会撑破 | 规整的卡片墙,但不想让列数变化 |
从实践经验看,GridCells.Adaptive是我用得最多的一个,因为移动端屏幕宽度碎片化太严重了。比如同样写Adaptive(minSize = 140.dp),在宽 360.dp 的手机上可能分成 2 列,在宽 412.dp 的手机上变成 3 列,在平板上变成 5 列。这个“自动列数”不是简单的除法,它内部会计算一行里最多能放几个不小于 minSize 的列,并可能会让列宽略大于 minSize,以保证刚好放满一行。记住一句话:Adaptive 只关心“最小宽度”,不关心精确列数,所以不要试图去推算某个 item 在第几行第几列,除非你用了 Fixed。
2. 关键参数与特性拆解:从滚动方向到跨列单元格
LazyVerticalGrid 的参数看着多,其实真正影响日常使用的就那几个。我挑几个最核心的,逐个分析它们的实际作用和选择理由。
2.1 滚动状态控制:rememberLazyGridState 与上下滚动行为
LazyVerticalGrid 默认就是上下滚动的。如果你想在代码里控制滚动位置,或者监听滚动状态,就需要一个LazyGridState,它和LazyListState的用法基本一致:
val gridState = rememberLazyGridState() LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 120.dp), state = gridState, modifier = Modifier.fillMaxSize() ) { items(100) { index -> Text("Item $index") } }拿到 gridState 后,你能用它做很多操作:
gridState.scrollToItem(index: Int):直接跳到某个 item,无动画。gridState.animateScrollToItem(index: Int):带动画滚动到某个 item。gridState.firstVisibleItemIndex:当前第一个可见 item 的索引,做阅读进度记录很方便。gridState.layoutInfo.visibleItemsInfo:获取当前可见 items 的信息,分页判断就靠它。
我经常用以下方式监听列表是不是滑到底部:
val shouldLoadMore by remember { derivedStateOf { val lastVisibleItem = gridState.layoutInfo.visibleItemsInfo.lastOrNull() lastVisibleItem != null && lastVisibleItem.index >= gridState.layoutInfo.totalItemsCount - 3 } }这个判断里用derivedStateOf包裹很重要。如果直接在 composition 里读取 layoutInfo,每次滚动都会触发重组;包一层 derivedStateOf 之后,只有“距离底部是否小于 3 个 item”这个布尔值发生变化时,才会触发重组。这一招在长列表性能优化里非常关键,我后面讲性能时会再提。
注意:
rememberLazyGridState()默认只记住滚动位置,如果页面因为配置变化重建,它也会通过 rememberSaveable 自动恢复位置。但如果列表数据是在 init 阶段异步加载的,可能出现“恢复的 item 索引超出了新数据的总数”的情况,建议在数据处理时加一层 min(index, totalCount - 1) 的保护。
2.2 userScrollEnabled 和 reverseLayout:两个容易被忽视的滚动开关
LazyVerticalGrid 还有两个跟滚动行为直接相关的参数:userScrollEnabled和reverseLayout。
先看 userScrollEnabled。默认是 true,表示用户可以通过手势滚动列表。当你把它设为 false 时,列表仍然可以通过代码滚动(scrollToItem 依然有效),但用户手指滑动不会有反应。这种场景我遇到比较多的有:弹窗内的固定网格选择器、配合父容器手势冲突的页面、或者数据变更时希望列表固定住不被打断的展示页。
再看 reverseLayout,这个参数默认是 false,内容从顶部开始排列并向下延伸。设为 true 之后,内容会“反过来”,起始位置在底部,滚动方向也反了。这跟聊天列表倒序加载是一个套路。对于网格,我实际用得比较少,但如果你做那种“下方固定一个输入框、上方网格消息记录倒序展示”的界面,把 reverseLayout 设为 true 后,列表内容会自动从底部开始,新增数据时视觉上还保持在底部,体验会好很多。
提示:reverseLayout 并不会真的帮你反转 item 内容,它只是改变了排列和滚动的方向。不要在 item 里面写镜像类的代码。
2.3 内容内边距与排列方式:contentPadding 和 Arrangement 的配合
网格列表和普通列表一样,内容区域和屏幕边缘之间需要留白。最直接的做法是给 modifier 加 padding,但这样会带来一个问题:滚动时,padding 区域也被滚走了。当你希望列表滚动到边缘时,内容依然能“贴住”某个边距,就要用contentPadding参数。
LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 80.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // items... }contentPadding 的常见用途是:页面顶部有 Tab,底部有 BottomBar,列表内容滚动到底时不能被 BottomBar 挡住,于是给 bottom 留出 BottomBar 的高度加一段间距。这样即使滑动到底,最后一行 item 也会停在 BottomBar 上方,不会出现“内容被遮住”的尴尬。
horizontalArrangement 和 verticalArrangement 是网格里用来控制排列方式的参数。最常见的是Arrangement.spacedBy(12.dp),它会在每两个 item 之间插入固定的间距。还有一些不太常用但很有用的排列方式,比如Arrangement.SpaceBetween、Arrangement.SpaceEvenly、Arrangement.Center等,可以让网格在一行没占满时,用不同的方式分配剩余空间。
2.4 跨行跨列:GridItemSpan 实现“通栏”和“跨列卡片”
网格布局里有一种很常见的需求:第一行是一个通栏广告条,下面才是正常的商品卡片;或者某几个 item 需要横跨两列。LazyVerticalGrid 处理这个需求的 API 叫spans,它的使用方式非常直接:
LazyVerticalGrid( columns = GridCells.Fixed(2) ) { item(span = { GridItemSpan(maxLineSpan) }) { BannerView() } items( items = productList, key = { it.id } ) { product -> ProductCard(product) } }GridItemSpan(maxLineSpan)表示这个 item 占满当前行的所有列,也就是“通栏”。在 Fixed(2) 的下,GridItemSpan(2)和GridItemSpan(maxLineSpan)效果是一样的。但如果你用了GridCells.Adaptive,列数并不固定,那就不要写死 2 或 3,直接用 maxLineSpan 才能保证永远整行。
跨列不会破坏网格的滚动逻辑。GridItemSpan 只影响当前 item 在网格中的占位大小,其他 item 依然按列排列。但当跨列的 item 总宽度比一列宽时,它的高度会被格子约束住,所以如果你发现跨列 item 的高度被压缩了,可以在 item 内部用Modifier.height()手动指定一个期望高度。
3. 实操示例:一个带通栏 Header、跨列卡片和分页加载的网格页面
理论讲完,直接上代码。我会从环境准备开始,逐步实现一个完整可运行的示例。
3.1 环境准备与依赖版本
先确认你的项目满足基本条件:Android Studio 版本建议使用最新稳定版,因为 Compose 编译器和 Kotlin 版本的绑定关系比较严格。我这边用的是:
- Kotlin 1.9.22
- Compose BOM 2024.02.00
- Android Gradle Plugin 8.2.0
- minSdk 24
- targetSdk 34
在 app 模块的 build.gradle.kts 里加入:
android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } } dependencies { implementation(platform("androidx.compose:compose-bom:2024.02.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling") }如果你用了图片加载库,比如 Coil,还需要加上io.coil-kt:coil-compose的依赖。我下面示例里用 Coil 加载网络图片展示卡片封面,不做缓存方案的话,它默认的内存缓存和磁盘缓存已经够用。
3.2 第一版:用 Adaptive 网格渲染一个商品列表
先做一个最简单的网格商品列表,这个版本能跑通“数据渲染”这一层。
假设有这样一个数据类:
data class Product( val id: Int, val title: String, val price: String, val imageUrl: String )然后写一个模拟数据源:
private fun mockProducts(count: Int): List<Product> = List(count) { index -> Product( id = index, title = "商品 $index", price = "¥${9.9 + index}", imageUrl = "https://picsum.photos/id/${100 + index}/300/300" ) }核心的 Composable 如下:
@Composable fun ProductGridDemo() { val products = remember { mockProducts(50) } LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 140.dp), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items( items = products, key = { it.id } ) { product -> ProductCard(product) } } } @Composable fun ProductCard(product: Product) { Card( modifier = Modifier.fillMaxWidth() ) { Column { AsyncImage( model = product.imageUrl, contentDescription = null, modifier = Modifier .fillMaxWidth() .aspectRatio(1f) ) Text( text = product.title, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = product.price, modifier = Modifier.padding(horizontal = 8.dp, bottom = 8.dp), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary ) } } }运行起来后,你会看到网格根据屏幕宽度自动排成两到三列,滚动手感顺滑。这里有一个细节:contentPadding用 12.dp 而不是直接给 modifier 加 padding,这样滚动到最后一行时,内容会停在距屏幕底部 12.dp 的位置,而不是整个列表贴着屏幕边缘。
3.3 第二版:加入通栏 Header 和分页加载
商品列表不可能永远只有 50 条,下拉加载更多是标配。我在这里把逻辑拆成三块:网格内容、滚动到底监听、加载状态标识。
先定义一个可观察的数据容器,用mutableStateListOf管理列表:
val products = remember { mutableStateListOf<Product>().apply { addAll(mockProducts(30)) } } var isLoading by remember { mutableStateOf(false) } val gridState = rememberLazyGridState()然后写一个防重复加载的监听:
val shouldLoadMore by remember { derivedStateOf { val layoutInfo = gridState.layoutInfo val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 lastVisibleIndex >= layoutInfo.totalItemsCount - 3 } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore && !isLoading) { isLoading = true // 模拟网络请求 delay(800) val newItems = mockProducts(20) products.addAll(newItems) isLoading = false } }网格内容部分:
LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 140.dp), state = gridState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // 通栏 Header item(span = { GridItemSpan(maxLineSpan) }) { PromotionBanner() } items( items = products, key = { it.id } ) { product -> ProductCard(product) } // 底部加载更多指示器 if (isLoading) { item(span = { GridItemSpan(maxLineSpan) }) { CircularProgressIndicator( modifier = Modifier .padding(16.dp) .size(32.dp) .align(Alignment.CenterHorizontally) ) } } }这段代码里有一个关键细节:加载更多指示器本身也是一个网格 item,用span = { GridItemSpan(maxLineSpan) }通栏居中展示。比起在网格外面套一个 Column,再把指示器放在网格下方,这种做法的好处是加载更多时会自然地被“计算”进滚动高度里,不会出现“网格已经滑到底了,但加载更多进度条还在屏幕外面”的情况。
3.4 第三版:状态保存、item 复用和性能优化
网格列表到后期最容易出的问题是:用户滑出去再滑回来,item 里的状态全丢了;或者列表变长后滚动开始掉帧。这两个问题的解法其实都写在 Compose 的“最佳实践”里,但很多人会忽略。
第一个是状态保存。如果你的 item 里有勾选状态、输入框内容、播放状态等,你在items里设置一个稳定的 key,Compose 就能在列表数据变化时尽量复用对应的 item,避免无关 item 重组。刚才代码里的key = { it.id }就是这个意思。如果 item 里还有需要跨配置变更保存的自定义状态,配合rememberSaveable单独保存,不要依赖 item 的数据对象。
第二个是 contentType。当网格里存在多种类型 item 时,设置 contentType 能帮 Compose 复用同一类型的 item 节点,减少重组开销。写法很简单:
items( items = products, key = { it.id }, contentType = { it::class.java.simpleName } ) { product -> ProductCard(product) }第三个常见的性能坑是 item 内部的布局开销。比如AsyncImage的占位图、解码尺寸,最好根据网格 item 的实际显示尺寸来,不要直接加载原图。Coil 里面可以设置size(300)或直接用Size.ORIGINAL避免大图占内存。图片解码本身也会造成滚动时的峰值卡顿,尤其是网络图没有本地缓存的时候。这里我给 Coil 配了内存缓存和磁盘缓存之后,滚动流畅度提升明显,这是我在实际项目里很推荐的优化方向。
4. 常见问题与排查技巧:滚动异常、性能卡顿与嵌套冲突
就算参数都会用了,真正上线时还是会遇见奇奇怪怪的问题。这一节我把我这几年遇到的典型坑整理成速查表,后面补充详细说明。
| 问题现象 | 常见原因 | 解决方法 |
|---|---|---|
| 滑动卡顿、掉帧 | item 组合太复杂、图片过大、state 读取范围过大 | 用 derivedStateOf 缩小 state 读取范围;给图片限制解码尺寸;检查 item 内是否有高开销布局 |
| 网格高度塌陷 | 把 LazyVerticalGrid 放进 Column 或又嵌套了一个 Lazy 组件 | 改用单一 Lazy 网格 + 多类型 item;或用 Modifier.heightIn 限高 |
| 滚动位置错乱 | 列表数据更新后 item 位置映射漂移 | 设置稳定 key;不要在 item 内用数组 index 做状态依据 |
| 点击事件总是被滑动吞掉 | 手势冲突 | 用 combinedClickable 或自定义手势检测 |
| 横竖屏切换时 index 越界 | rememberSaveable 恢复了旧索引 | 恢复后对索引做边界限制 |
| 跨列 item 高度被压缩 | GridItemSpan 占位后高度只跟内容相关 | 手动指定高度或使用 Modifier.height |
下面挑几个具体展开。
4.1 滚动卡顿:先定位重组范围
网格列表卡顿,不要一上来就怀疑 Compose 性能不行,绝大多数问题是“状态读取范围太大”导致的。比如你直接在 item 的 Composable 函数里读取了一个viewModel的 LiveData,那这个 item 每次数据变化都会重组,如果列表有几百个 item,重组风暴一来,神仙也顶不住。
排查方法很简单:用 Android Studio 自带的 Layout Inspector,打开 “Show Recomposition Counts”,滑动列表看哪个 item 的重组次数异常高。只要看到某些不相关的 item 也在疯狂重组,基本就能定位到问题。
我自己的项目里遇到过这种情况:商品卡片里展示了一个“剩余库存”字段,这个字段来自一个全局的库存 Map,滚动时库存变化会引起所有商品 item 重组。后来我把“剩余库存”抽成了一个独立的子 Composable,并且用derivedStateOf包装读取逻辑,重组范围瞬间缩小,滑动又恢复了顺滑。
4.2 嵌套 Lazy 组件的“高度塌陷”问题
这是一个非常经典的坑:把 LazyVerticalGrid 放进 Column,或者网格里面再套一个 LazyRow,经常会导致运行时崩溃或者布局怪异。原因是 Lazy 组件在测量时只能知道自己是否需要无限滚动,它没有办法在“无限高度”的父布局里主动测量出一个确定的高度。
有两种常见的解法:
- 如果网格内容比较少,固定高度,可以让网格直接占满 Column 的剩余权重空间,或者手动给它
Modifier.height(300.dp)。 - 如果网格属于页面的一个分块,建议把页面整体的结构设计成“外层一个 LazyVerticalGrid,内部通过多类型 item + spans 实现不同模块”。不要把“外层竖向滚动”和“内层网格”拆成两个 Lazy 组件去套。
至于网格 item 内横向套 LazyRow,这个相对安全,因为横向滚动不影响竖向测量。但如果 LazyRow 外面再包一层横向的固定高度约束,还是有可能出现测量问题,建议给 LazyRow 的父布局加上一个明确的Modifier.height(),避免内部无限宽。
4.3 item 状态丢失与滚动位置跳动
你说我没写错代码,为什么滑出去再滑回来,item 里的文字输入框内容就没了?绝大多数原因是:你没有给 items 设置 key。当列表数据源发生变化,Compose 默认用 item 的索引来做身份标识,只要数据增删,后面所有 item 都会被当成“新 item”,状态自然无法保留。
正确做法是给 items 设置独立且稳定的 key。比如key = { it.id },id 必须是业务上唯一的字段,不要用 index。如果数据源是网络返回,且没有 id 字段,你可以给数据类添加一个本地生成的 UUID,反正原则是:保证同一个业务对象在数据更新前后 key 不变。
另一种情况是数据加载更多后,用户明明停在某个位置,列表却跳到了顶部。这个问题常见于没有保留滚动状态,或者列表数据源被整体替换导致 item 数量突变。我用的是rememberLazyGridState(),它能跨重组保存状态;但如果数据源是一个mutableStateListOf,注意不要在加载更多时把整个 list 重新赋值,用addAll这种增量操作,能显著减少滚动位置的跳动。
4.4 网格的长按拖拽、点击与滚动的手势冲突
网格做可拖拽排序时,最容易遇到的手势冲突是“拖动和滚动分不清”。我尝试过几种思路,最可靠的做法是:给需要拖拽的 item 加上combinedClickable,在 onLongClick 里开启拖拽模式,拖拽是否开始的判定等 touch slop 过了再决定,不要在 onLongClick 里立刻改变列表局部状态,否则容易造成抖动。
如果你用了detectDragGesturesAfterLongPress这样的自定义 Modifier,建议配合pointerInput里的awaitEachGesture手动检测。这个方案写起来略烦,但控制力最强。简单场景下,直接给 item 加一个显眼的“拖拽手柄”图标,只在手柄区域响应拖拽,能避开大部分手势冲突。
5. 进阶:LazyVerticalGrid 与同类滚动容器的对比与选型
学到一个组件之后,还要知道它跟身边兄弟组件之间的边界在哪里。选错容器,后期维护会很难受。
5.1 LazyColumn、LazyVerticalGrid、StaggeredGrid 三兄弟对比
| 对比维度 | LazyColumn | LazyVerticalGrid | LazyVerticalStaggeredGrid |
|---|---|---|---|
| 布局形态 | 单列竖向滚动 | 多列竖向滚动,行内列对齐 | 多列竖向滚动,列内错落排列 |
| 适用场景 | 消息列表、设置页、评论列表 | 商品列表、相册缩略图、宫格入口 | 瀑布流、卡片高度差异大的场景 |
| 跨行跨列 | 不支持 | GridItemSpan 支持 | 不支持 |
| 列数控制 | 无 | Fixed / Adaptive / FixedSize | 类似 Grid,但 item 高度不受行约束 |
| 懒加载 | 支持 | 支持 | 支持 |
LazyVerticalStaggeredGrid 适合那种卡片高度不完全一致的瀑布流,比如小红书首页。但它和 LazyVerticalGrid 的 item 测量机制不同,瀑布流每个 item 高度是独立计算的,跨列能力反而没有 LazyVerticalGrid 灵活。
5.2 什么时候别用 LazyVerticalGrid
网格数量很少且固定时,不一定非要用 LazyVerticalGrid。比如页面上只有 4 个功能入口,用一个Column + Row或者 FlowRow 反而更轻量,还少了懒加载组件本身的测量开销。LazyVerticalGrid 的优势在高数据量场景下才明显,数据少时优化收益不高,代码却会多一层复杂度。
另外,如果你要做“表格式”的网格,比如 Excel 那种行列都要固定头部、横向纵纵向都要滚动的表格,LazyVerticalGrid 并不适合。它只支持单一方向滚动,不支持行列锁定。这种需求还是得用第三方表格库,或者自己基于 Canvas 绘制。
5.3 横向滚动方案:LazyHorizontalGrid 与 LazyVerticalGrid 的对应关系
标题说的是“上下滚动”的网格布局,但实际开发中也会有横向滚动网格的场景。Compose 里其实还有LazyHorizontalGrid,用法和 LazyVerticalGrid 几乎一致,只是滚动方向变成了水平方向,原来的行变成列。使用感受上,从 LazyVerticalGrid 换成 LazyHorizontalGrid 只需要改组件名和 Arrangement 参数,代码结构不用大动:
LazyHorizontalGrid( rows = GridCells.Fixed(2), modifier = Modifier.height(200.dp) ) { items(20) { index -> Text("Item $index") } }这种横向网格适合做“最近浏览记录”“排行榜”之类的横向滑动列表,但要注意,它的高度必须明确给定,否则在 Column 里会塌陷成一个 0.dp 高度的不可见组件。
我自己在实际项目里用得最顺手的一个组合是:外层竖向列表用 LazyVerticalGrid 做主要内容的网格展示,遇到需要横向滚动的分组时,在每一个 item 里再嵌套一个固定高度的 LazyRow。这样既能保证整页的竖向滚动无限加载,又在局部实现了横向滑动的灵活性。做之前先想清楚数据结构,别把页面拆成很多个“整页滚动容器”,否则后续手势和状态管理会非常头疼。
最后再分享一个我自己特别喜欢的小技巧:当列表数据量很大,你希望用户能快速回到顶部时,除了用animateScrollToItem(0),还可以在网格上加一个“回到顶部”的悬浮按钮,按钮的显示和隐藏用gridState.firstVisibleItemIndex > 0配合AnimatedVisibility控制。这个小交互在电商 App 里几乎成了标配,但实现起来真的只要十几行代码。网格布局本身不复杂,复杂的是把各种滚动状态、数据状态、UI 状态在 Compose 的声明式思维里理顺。从一个小 Demo 开始,慢慢加需求、加性能优化,你会越来越喜欢这种写列表的方式。