自定义 ItemDecoration:绘制复杂的分隔线或分组标签
说实话,RecyclerView 的分隔线,很多人第一反应就是 DividerItemDecoration。嗯,官方确实给了个现成的,但用过的都知道——太死板了。一条灰线从头画到尾,想加点花样?没门。
我当年做第一个电商项目时,产品经理要求商品列表的分组标签要带圆角背景,分隔线还要根据商品类型变色。当时我愣是翻遍了官方文档,最后发现——ItemDecoration 才是真正的王牌。
核心认知:ItemDecoration 不是只能画线。它可以在每个 item 的四周「画任何东西」——文字、图形、圆角矩形、甚至图标。说白了,它就是 RecyclerView 的「画布扩展层」。
ItemDecoration 的工作原理
先别急着写代码。咱们得搞清楚它到底怎么工作的。
RecyclerView 在布局每个 item 时,会调用 getItemOffsets() 来问:「这个 item 要留多少空白?」然后绘制时再调用 onDraw() 和 onDrawOver() 来实际画东西。
- getItemOffsets():预留空间。你在这里告诉 RecyclerView:「第 3 个 item 上面要留 40px 给分组标签」。
- onDraw():画在 item 下面。适合画分隔线、背景色。
- onDrawOver():画在 item 上面。适合画悬浮标签、角标。
我习惯把 getItemOffsets 比作「打地基」,onDraw 和 onDrawOver 是「装修」。地基没打好,装修再好也白搭。
实战:带分组标签的复杂分隔线
咱们直接上一个真实场景:通讯录列表。每个分组(按首字母)顶部要显示一个带圆角的标签,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 绘制流程
进阶:悬浮分组标签
你想想看,通讯录列表滑动时,分组标签能不能像 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.top 或 parent.computeVerticalScrollOffset() 动态计算。
性能注意事项
ItemDecoration 的 onDraw 和 onDrawOver 是在 UI 线程执行的。画的东西越多,卡顿风险越大。我一般遵循几个原则:
| 原则 | 说明 |
|---|---|
| 避免创建对象 | Paint、Path 等在构造方法中创建,不要在 onDraw 里 new |
| 减少 Canvas 操作 | 能用 drawLine 就别用 drawPath,能用 drawRect 就别用 drawRoundRect |
| 提前计算 | 文字宽度、位置等在数据变化时算好,不要每帧都算 |
| 按需绘制 | 只画可见区域的 item,不要遍历全部数据 |
我的习惯:如果分隔线逻辑特别复杂(比如根据数据类型画不同颜色、不同粗细),我会把「绘制参数」提前算好,存到一个数组里。onDraw 时直接取用,不做任何计算。这样能保证 60fps 流畅滑动。
总结
ItemDecoration 是个被低估的 API。很多人只拿它画条线,其实它能做的事太多了:分组标签、时间轴、进度条、甚至水印。说白了,只要你想在 item 周围「加料」,都可以用 ItemDecoration 实现。
关键就三点:getItemOffsets 留空间,onDraw 画底层,onDrawOver 画顶层。把这三点吃透了,你就能玩出各种花样。