10、瀑布流布局:StaggeredGridLayoutManager 实现不等高卡片
说实话,瀑布流布局在移动端太常见了。你打开小红书、花瓣、Pinterest,满屏都是这种错落有致的卡片墙。今天我们就来聊聊怎么用 RecyclerView 实现它。
核心工具就是 StaggeredGridLayoutManager。名字挺长,但说白了就是「交错网格布局管理器」。它跟 GridLayoutManager 最大的区别——每一列的 item 可以拥有不同的高度。
为什么需要 StaggeredGridLayoutManager?
我刚开始做瀑布流时,第一反应是用 GridLayoutManager + 动态设置 item 高度。结果发现一个问题:GridLayoutManager 强制所有 item 对齐到网格线,你就算把某个 item 设得很高,它也不会「挤」到上一行去。
举个例子:
- GridLayoutManager:三列,每行高度由最高的 item 决定,其他 item 底部留白
- StaggeredGridLayoutManager:三列,每个 item 独立高度,下一行的 item 会「见缝插针」往上填
这就是瀑布流的核心——视觉上更紧凑,内容展示效率更高。
一句话总结:StaggeredGridLayoutManager 允许每一列的 item 高度独立,形成错落有致的布局效果。
基本用法
先看最简单的代码。假设我们有一个图片列表,每张图片高度不同:
// 1. 创建布局管理器
val layoutManager = StaggeredGridLayoutManager(
2, // 列数
StaggeredGridLayoutManager.VERTICAL // 方向
)
// 2. 设置给 RecyclerView
recyclerView.layoutManager = layoutManager
// 3. 适配器照常写
recyclerView.adapter = MyAdapter(dataList)
嗯,就这么简单。三行代码,瀑布流就出来了。
但注意——适配器里的 onBindViewHolder 需要根据数据动态设置 item 高度。比如图片高度是 300px,你就不能硬编码成固定值。
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = dataList[position]
// 动态设置高度
val layoutParams = holder.itemView.layoutParams
layoutParams.height = item.height // 从数据中获取
holder.itemView.layoutParams = layoutParams
}
小技巧:如果图片是从网络加载的,建议在图片加载完成后再设置高度。否则 Glide/Picasso 还没加载完,高度为 0,布局就乱了。我习惯在图片加载回调里调用 notifyItemChanged(position)。
避坑指南:item 复用导致的高度错乱
我曾经在项目里踩过一个坑——滑动 RecyclerView 时,某些卡片的高度突然变了。排查了半天,发现是 item 复用导致的。
原因很简单:RecyclerView 回收 item 时,会保留之前的高度。如果新数据的高度不同,但你没有在 onBindViewHolder 里重新设置,就会显示旧高度。
解决方案:
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
// 每次绑定都强制重新设置高度
val item = dataList[position]
val layoutParams = holder.itemView.layoutParams
layoutParams.height = item.height
holder.itemView.layoutParams = layoutParams
}
说白了,就是「每次绑定都刷新高度」。不要依赖 item 的默认值。
StaggeredGridLayoutManager 的常用 API
| 方法 | 作用 |
|---|---|
setSpanCount(int) |
动态修改列数 |
setOrientation(int) |
设置方向(VERTICAL / HORIZONTAL) |
getSpanCount() |
获取当前列数 |
findFirstVisibleItemPositions(int[]) |
获取每列第一个可见 item 的位置 |
findLastVisibleItemPositions(int[]) |
获取每列最后一个可见 item 的位置 |
我个人最常用的是 findFirstVisibleItemPositions 和 findLastVisibleItemPositions。比如做「回到顶部」功能时,需要知道当前哪一列在最上面。
核心逻辑流程图
下面这张图展示了 StaggeredGridLayoutManager 的核心工作流程:
进阶:处理 item 跨列(占多列)
有时候我们需要某个 item 占两列宽度,比如广告卡片或大图。StaggeredGridLayoutManager 支持这个功能,但需要配合 StaggeredGridLayoutManager.LayoutParams 使用。
// 在适配器中判断是否需要跨列
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = dataList[position]
if (item.isFullWidth) {
val layoutParams = holder.itemView.layoutParams as StaggeredGridLayoutManager.LayoutParams
layoutParams.isFullSpan = true // 占满所有列
}
}
注意:isFullSpan 设置为 true 后,这个 item 会独占一行,其他 item 会排在它下面。这在瀑布流里很常见,比如小红书的信息流广告。
注意:跨列 item 会破坏瀑布流的「错落感」。如果你大量使用跨列,建议考虑改用 GridLayoutManager + 自定义 SpanSizeLookup。StaggeredGridLayoutManager 更适合纯瀑布流场景。
性能优化:避免频繁测量
瀑布流布局有个天然的性能问题——每个 item 高度不同,RecyclerView 需要频繁测量。我见过一个项目,列表里有 200 张图片,滑动时卡成 PPT。
优化方案:
- 固定 item 高度:如果图片宽高比固定,可以在数据层提前计算好高度,避免在
onBindViewHolder里实时计算 - 使用 ViewHolder 缓存:不要每次都 new LayoutParams,复用已有的
- 减少布局层级:卡片布局尽量扁平,避免多层嵌套
// 提前计算高度,避免实时计算
data class CardItem(
val imageUrl: String,
val width: Int,
val height: Int,
val displayHeight: Int // 提前算好的展示高度
)
我个人习惯在数据层就把 displayHeight 算好。比如图片原始宽高是 800x600,卡片宽度是 200dp,那么高度就是 150dp。这样适配器里直接赋值,省去了实时计算的性能开销。
总结
StaggeredGridLayoutManager 实现瀑布流,核心就三点:
- 设置布局管理器为 StaggeredGridLayoutManager
- 在适配器中动态设置 item 高度
- 注意 item 复用导致的高度错乱
嗯,其实没那么复杂。你想想看,RecyclerView 的布局管理器就像个「排版工人」,你告诉它「我要两列,每列高度独立」,它就能自动帮你排好。你要做的,就是给每个 item 一个正确的高度值。
我在实际项目中,瀑布流用得最多的场景就是图片展示类 App。只要处理好图片加载和高度计算,效果非常丝滑。如果你刚开始接触,建议先拿一个简单的图片列表练手,把基础流程跑通,再考虑跨列、动画等进阶功能。
核心口诀:列数固定、高度动态、复用重置、性能优先。