14、ItemDecoration:给列表项添加分割线、间距、背景

说实话,RecyclerView 刚出来那会儿,我第一反应是:「这玩意儿怎么连个分割线都没有?」

ListView 自带一个 divider 属性,多省事。但 RecyclerView 的设计哲学不一样——它把「装饰」这件事完全交给你自己控制。这就是 ItemDecoration 存在的意义。

我个人习惯把 ItemDecoration 理解为「列表项的化妆师」。它不改变数据,不改变布局,只在绘制阶段给每个 item 加上分割线、间距、背景,甚至还能画一些花哨的装饰效果。

14.1 ItemDecoration 的核心原理

ItemDecoration 有三个回调方法,但大部分场景我们只用两个:

  • getItemOffsets() —— 控制 item 之间的间距
  • onDraw() —— 在 item 内容之下绘制装饰
  • onDrawOver() —— 在 item 内容之上绘制装饰(比如粘性头部)

你想想看,RecyclerView 绘制一个列表项的顺序是:先画背景 → 调用 onDraw() → 画 item 内容 → 调用 onDrawOver()。所以分割线通常放在 onDraw() 里,因为它在 item 内容下面,不会遮挡文字。

关键点:getItemOffsets() 决定了「留多少空白」,onDraw() 决定了「在空白里画什么」。两者配合才能做出好看的分割线。

14.2 最常用的分割线实现

我在项目中写过不下十种分割线方案,但最常用的还是这个——一个简单的水平分割线,带左右边距:

class SimpleDividerItemDecoration(
    private val dividerHeight: Int = 1,
    private val dividerColor: Int = Color.LTGRAY,
    private val leftMargin: Int = 0,
    private val rightMargin: Int = 0
) : RecyclerView.ItemDecoration() {

    private val paint = Paint().apply {
        color = dividerColor
        strokeWidth = dividerHeight.toFloat()
    }

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        // 最后一项不添加间距
        if (parent.getChildAdapterPosition(view) != parent.adapter?.itemCount?.minus(1)) {
            outRect.bottom = dividerHeight
        }
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        val left = parent.paddingLeft + leftMargin
        val right = parent.width - parent.paddingRight - rightMargin

        for (i in 0 until parent.childCount - 1) {
            val child = parent.getChildAt(i)
            val top = child.bottom
            val bottom = top + dividerHeight
            c.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint)
        }
    }
}

使用起来也很简单:

recyclerView.addItemDecoration(
    SimpleDividerItemDecoration(
        dividerHeight = 2,
        dividerColor = Color.parseColor("#E0E0E0"),
        leftMargin = 16,
        rightMargin = 16
    )
)

小技巧:如果你用的是 Kotlin,可以给 RecyclerView 写一个扩展函数,把常用的分割线封装起来。我一般会在项目里放一个 DividerDecorationFactory 单例,统一管理所有分割线样式。

14.3 给列表项添加间距

有时候你不需要分割线,只需要 item 之间有点呼吸感。比如卡片式列表,每个卡片之间留 8dp 间距就够了。

这种情况,我建议直接在 getItemOffsets() 里设置间距,不用画任何东西:

class SpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        outRect.left = spacing
        outRect.right = spacing
        outRect.top = spacing
        outRect.bottom = spacing
    }
}

嗯,这里要注意:如果每个 item 都加了左右间距,第一个 item 左边会有多余空白。你可以判断一下 position,如果是第一列就只加右边距,最后一列只加左边距。不过 GridLayoutManager 的情况更复杂,我后面会讲。

14.4 给列表项添加背景

背景其实不一定要用 ItemDecoration。你完全可以在 item 布局里设置 android:background。但有些场景下,你需要动态改变背景,或者给不同位置的 item 设置不同背景,这时候 ItemDecoration 就派上用场了。

我曾经做过一个聊天列表,需要给每条消息交替显示浅灰和白色背景。用 ItemDecoration 实现非常优雅:

class AlternatingBackgroundDecoration(
    private val evenColor: Int = Color.WHITE,
    private val oddColor: Int = Color.parseColor("#F5F5F5")
) : RecyclerView.ItemDecoration() {

    private val paint = Paint()

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val position = parent.getChildAdapterPosition(child)

            paint.color = if (position % 2 == 0) evenColor else oddColor
            c.drawRect(
                child.left.toFloat(),
                child.top.toFloat(),
                child.right.toFloat(),
                child.bottom.toFloat(),
                paint
            )
        }
    }
}

注意:如果你在 item 布局里也设置了背景,ItemDecoration 绘制的背景会被覆盖。解决办法是让 item 布局的背景透明,或者把 ItemDecoration 的绘制放在 onDrawOver() 里。

14.5 GridLayoutManager 下的特殊处理

网格布局的分割线比线性布局复杂得多。你需要考虑每一行最后一列不要右边距,每一列最后一行不要下边距。

我封装了一个通用的网格分割线工具,核心逻辑是这样的:

class GridDividerDecoration(
    private val spanCount: Int,
    private val spacing: Int,
    private val includeEdge: Boolean = false
) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildAdapterPosition(view)
        val column = position % spanCount

        if (includeEdge) {
            outRect.left = spacing - column * spacing / spanCount
            outRect.right = (column + 1) * spacing / spanCount
            if (position < spanCount) {
                outRect.top = spacing
            }
            outRect.bottom = spacing
        } else {
            outRect.left = column * spacing / spanCount
            outRect.right = spacing - (column + 1) * spacing / spanCount
            if (position >= spanCount) {
                outRect.top = spacing
            }
        }
    }
}

这段代码我改过好几版。最开始我直接用 outRect.left = spacing / 2 这种写法,结果发现第一列和最后一列的间距不对称。后来参考了 Material Design 的规范,才用了这种按比例分配的方式。

14.6 知识体系总览

下面这张图总结了 ItemDecoration 的核心知识点和常见用法:

ItemDecoration 知识体系 ItemDecoration getItemOffsets() onDraw() onDrawOver() 分割线 线性/网格/自定义样式 间距 卡片间距/边距控制 背景 交替色/选中高亮 注意事项 最后一项处理 · 网格边界对齐 · 性能优化(避免在 onDraw 中创建对象)

14.7 性能注意事项

ItemDecoration 的 onDraw()onDrawOver() 会在每次列表滚动时被频繁调用。所以:

  • 不要在 onDraw 里创建对象——Paint、Rect 这些应该提前初始化好
  • 避免复杂的计算——比如每次都重新算一遍 item 位置,可以缓存起来
  • 如果分割线很简单——直接用 DividerItemDecoration 这个官方类就够了,别自己造轮子

我的经验:曾经在一个聊天列表里用了复杂的 ItemDecoration 画气泡背景,结果列表快速滑动时明显卡顿。后来把背景改到 item 布局里用 ShapeDrawable 实现,流畅度提升了一大截。所以,能用布局解决的,就别用 ItemDecoration。

14.8 实战建议

最后给你几个我在项目中总结出来的建议:

  1. 分割线颜色别用纯黑——用 #E0E0E0 或者更浅的颜色,视觉上更舒服
  2. 间距单位用 dp——别写死 px,不同屏幕密度下效果差很多
  3. 考虑 RTL 布局——如果你的 App 支持阿拉伯语,分割线的左右边距要反过来
  4. 调试时把分割线颜色设成红色——一眼就能看出间距有没有生效

ItemDecoration 这东西,说白了就是「画图」。你掌握了 Canvas 的基本操作,就能玩出各种花样。但别忘了,它的本质是装饰,别喧宾夺主。


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