自定义 ItemDecoration:绘制复杂的分隔线或分组标签

说实话,RecyclerView 的分隔线,很多人第一反应就是 DividerItemDecoration。嗯,官方确实给了个现成的,但用过的都知道——太死板了。一条灰线从头画到尾,想加点花样?没门。

我当年做第一个电商项目时,产品经理要求商品列表的分组标签要带圆角背景,分隔线还要根据商品类型变色。当时我愣是翻遍了官方文档,最后发现——ItemDecoration 才是真正的王牌

核心认知:ItemDecoration 不是只能画线。它可以在每个 item 的四周「画任何东西」——文字、图形、圆角矩形、甚至图标。说白了,它就是 RecyclerView 的「画布扩展层」。

ItemDecoration 的工作原理

先别急着写代码。咱们得搞清楚它到底怎么工作的。

RecyclerView 在布局每个 item 时,会调用 getItemOffsets() 来问:「这个 item 要留多少空白?」然后绘制时再调用 onDraw()onDrawOver() 来实际画东西。

  • getItemOffsets():预留空间。你在这里告诉 RecyclerView:「第 3 个 item 上面要留 40px 给分组标签」。
  • onDraw():画在 item 下面。适合画分隔线、背景色。
  • onDrawOver():画在 item 上面。适合画悬浮标签、角标。

我习惯把 getItemOffsets 比作「打地基」,onDrawonDrawOver 是「装修」。地基没打好,装修再好也白搭。

实战:带分组标签的复杂分隔线

咱们直接上一个真实场景:通讯录列表。每个分组(按首字母)顶部要显示一个带圆角的标签,item 之间要有浅灰色的分隔线,但分组之间分隔线要加粗。

class ContactDecoration(private val context: Context) : RecyclerView.ItemDecoration() {

    // 先定义一些常量
    private val labelHeight = dp2px(36f)
    private val dividerHeight = dp2px(1f)
    private val groupDividerHeight = dp2px(8f)
    private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.parseColor("#FF6200EE")
        textSize = dp2px(14f)
        isFakeBoldText = true
    }
    private val dividerPaint = Paint().apply {
        color = Color.parseColor("#E0E0E0")
        strokeWidth = dp2px(1f)
    }

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildAdapterPosition(view)
        val adapter = parent.adapter as? ContactAdapter ?: return

        // 判断是否是分组第一个
        if (isFirstInGroup(position, adapter)) {
            outRect.top = labelHeight + groupDividerHeight
        } else {
            outRect.top = dividerHeight
        }
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        val adapter = parent.adapter as? ContactAdapter ?: return

        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val position = parent.getChildAdapterPosition(child)

            if (isFirstInGroup(position, adapter)) {
                // 画分组标签
                val left = child.left + dp2px(12f)
                val top = child.top - labelHeight - groupDividerHeight
                val right = child.right - dp2px(12f)
                val bottom = child.top - groupDividerHeight

                // 画圆角背景
                val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
                    color = Color.parseColor("#FFF3E0")
                }
                val rectF = RectF(left, top, right, bottom)
                c.drawRoundRect(rectF, dp2px(8f), dp2px(8f), bgPaint)

                // 画文字
                val groupName = adapter.getGroupName(position)
                val textX = left + dp2px(12f)
                val textY = bottom - dp2px(8f)
                c.drawText(groupName, textX, textY, labelPaint)
            } else {
                // 画普通分隔线
                val left = child.left + dp2px(16f)
                val right = child.right - dp2px(16f)
                val y = child.top.toFloat()
                c.drawLine(left, y, right, y, dividerPaint)
            }
        }
    }

    private fun isFirstInGroup(position: Int, adapter: ContactAdapter): Boolean {
        if (position == 0) return true
        val currentGroup = adapter.getGroupName(position)
        val previousGroup = adapter.getGroupName(position - 1)
        return currentGroup != previousGroup
    }

    private fun dp2px(dp: Float): Float {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics
        )
    }
}

我的经验:画文字时,Y 坐标要用文字的 baseline,而不是 bottom。很多人第一次写都会把文字画歪。用 Paint.getFontMetrics() 算一下 baseline 偏移量,能省很多调试时间。

分组逻辑与适配器配合

ItemDecoration 本身不存数据。它需要从 Adapter 获取「当前 item 属于哪个组」。我一般会在 Adapter 里暴露两个方法:

class ContactAdapter(private val contacts: List<Contact>) : RecyclerView.Adapter<ViewHolder>() {

    // 暴露给 ItemDecoration 使用
    fun getGroupName(position: Int): String {
        return contacts[position].name.first().uppercase()
    }

    fun isFirstInGroup(position: Int): Boolean {
        if (position == 0) return true
        return getGroupName(position) != getGroupName(position - 1)
    }
}

这样 ItemDecoration 和 Adapter 就解耦了。你换任何 Adapter,只要实现了这两个方法,Decoration 就能复用。

SVG 流程图:ItemDecoration 绘制流程

ItemDecoration 绘制流程 RecyclerView 布局 item getItemOffsets() → 预留空间 onDraw() → 画在 item 下方 onDrawOver() → 画在 item 上方 最终显示:item + 装饰层

进阶:悬浮分组标签

你想想看,通讯录列表滑动时,分组标签能不能像 iOS 那样「吸顶」?当然可以。用 onDrawOver() 就能实现。

核心思路:找到当前屏幕上第一个可见的 item,判断它是不是分组第一个。如果是,就把标签画在 RecyclerView 的顶部。

override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
    val adapter = parent.adapter as? ContactAdapter ?: return

    // 找到第一个完全可见的 item
    val firstVisiblePosition = parent.getChildAdapterPosition(parent.getChildAt(0))
    if (firstVisiblePosition == RecyclerView.NO_POSITION) return

    // 获取当前分组名
    val currentGroup = adapter.getGroupName(firstVisiblePosition)

    // 画悬浮标签
    val left = parent.left + dp2px(12f)
    val top = parent.top.toFloat()
    val right = parent.right - dp2px(12f)
    val bottom = top + labelHeight

    val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.parseColor("#FF6200EE")
    }
    val rectF = RectF(left, top, right, bottom)
    c.drawRoundRect(rectF, dp2px(8f), dp2px(8f), bgPaint)

    // 白色文字
    labelPaint.color = Color.WHITE
    val textX = left + dp2px(12f)
    val textY = bottom - dp2px(8f)
    c.drawText(currentGroup, textX, textY, labelPaint)
}

我曾经踩过的坑:悬浮标签的 Y 坐标不能写死。如果 RecyclerView 上方有 Toolbar 或其他头部,标签会被挡住。一定要用 parent.topparent.computeVerticalScrollOffset() 动态计算。

性能注意事项

ItemDecoration 的 onDrawonDrawOver 是在 UI 线程执行的。画的东西越多,卡顿风险越大。我一般遵循几个原则:

原则 说明
避免创建对象 Paint、Path 等在构造方法中创建,不要在 onDraw 里 new
减少 Canvas 操作 能用 drawLine 就别用 drawPath,能用 drawRect 就别用 drawRoundRect
提前计算 文字宽度、位置等在数据变化时算好,不要每帧都算
按需绘制 只画可见区域的 item,不要遍历全部数据

我的习惯:如果分隔线逻辑特别复杂(比如根据数据类型画不同颜色、不同粗细),我会把「绘制参数」提前算好,存到一个数组里。onDraw 时直接取用,不做任何计算。这样能保证 60fps 流畅滑动。

总结

ItemDecoration 是个被低估的 API。很多人只拿它画条线,其实它能做的事太多了:分组标签、时间轴、进度条、甚至水印。说白了,只要你想在 item 周围「加料」,都可以用 ItemDecoration 实现。

关键就三点:getItemOffsets 留空间,onDraw 画底层,onDrawOver 画顶层。把这三点吃透了,你就能玩出各种花样。

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