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 的位置

我个人最常用的是 findFirstVisibleItemPositionsfindLastVisibleItemPositions。比如做「回到顶部」功能时,需要知道当前哪一列在最上面。

核心逻辑流程图

下面这张图展示了 StaggeredGridLayoutManager 的核心工作流程:

StaggeredGridLayoutManager 核心流程 设置布局管理器 适配器绑定数据 动态设置 item 高度 瀑布流效果呈现

进阶:处理 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 实现瀑布流,核心就三点:

  1. 设置布局管理器为 StaggeredGridLayoutManager
  2. 在适配器中动态设置 item 高度
  3. 注意 item 复用导致的高度错乱

嗯,其实没那么复杂。你想想看,RecyclerView 的布局管理器就像个「排版工人」,你告诉它「我要两列,每列高度独立」,它就能自动帮你排好。你要做的,就是给每个 item 一个正确的高度值。

我在实际项目中,瀑布流用得最多的场景就是图片展示类 App。只要处理好图片加载和高度计算,效果非常丝滑。如果你刚开始接触,建议先拿一个简单的图片列表练手,把基础流程跑通,再考虑跨列、动画等进阶功能。

核心口诀:列数固定、高度动态、复用重置、性能优先。

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