13、RecyclerView的ItemDecoration与ItemAnimator:分割线绘制与增删动画的自定义

RecyclerView 的强大之处,不光在于它能高效回收 View。说实话,真正让 RecyclerView 变得灵活好用的,是它这套扩展机制。今天咱们就聊聊两个关键角色:ItemDecorationItemAnimator

一个管“画”,一个管“动”。

我刚开始接触 RecyclerView 时,总觉得分割线不就是画条线嘛,有什么好讲的?后来在项目里遇到各种奇葩设计——带间距的卡片、带圆角的分割线、甚至分割线里还要显示文字……嗯,那时候我才意识到,ItemDecoration 其实是个被低估的 API。

ItemDecoration:不只是画分割线

ItemDecoration 的官方定义是:对 RecyclerView 的 item 进行装饰。说白了,就是在 item 绘制之前或之后,往 Canvas 上加点东西。

它有三个核心方法:

  • onDraw() — 在 item 绘制之前调用,画在 item 下面
  • onDrawOver() — 在 item 绘制之后调用,画在 item 上面
  • getItemOffsets() — 设置 item 的偏移量,相当于给 item 留出“装饰空间”

很多人只用了 getItemOffsets() 来设置间距,却忽略了 onDraw()onDrawOver() 的绘制能力。其实,这三个方法配合起来,能实现很多花哨的效果。

一个简单的分割线实现

先来个最基础的。假设我们要在每个 item 底部画一条灰色的线:

class SimpleDividerDecoration(context: Context) : RecyclerView.ItemDecoration() {
    private val divider = ContextCompat.getDrawable(context, R.drawable.divider_shape)
    private val dividerHeight = 1.dp

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        // 给每个 item 底部留出分割线的高度
        outRect.bottom = dividerHeight
    }

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

        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val params = child.layoutParams as RecyclerView.LayoutParams

            // 分割线的 top 就是 child 的 bottom
            val top = child.bottom + params.bottomMargin
            val bottom = top + dividerHeight

            divider?.setBounds(left, top, right, bottom)
            divider?.draw(c)
        }
    }
}

这里有个细节:getItemOffsets()onDraw() 必须配合使用。前者告诉 RecyclerView “我要占多少空间”,后者才真正去画。如果你只画了线,没设置偏移,线会被 item 盖住。

注意: 我见过有人只在 onDraw() 里画线,没写 getItemOffsets(),结果线被 item 遮挡了。调试了半天才发现是偏移没设。嗯,这个坑我踩过。

更复杂的场景:带间距的网格分割线

如果是 GridLayoutManager,分割线就复杂了。不光要画横线,还要画竖线。而且每个 item 的偏移量需要同时考虑左右和上下。

我曾经在项目里做过一个“卡片式网格”,每个卡片之间要有 8dp 的间距,但最左边和最右边不需要。实现思路是这样的:

override fun getItemOffsets(
    outRect: Rect,
    view: View,
    parent: RecyclerView,
    state: RecyclerView.State
) {
    val position = parent.getChildAdapterPosition(view)
    val spanCount = 2  // 两列网格
    val spacing = 8.dp

    val column = position % spanCount

    // 左边距:第一列没有左边距,第二列有
    outRect.left = if (column == 0) 0 else spacing
    // 右边距:最后一列没有右边距
    outRect.right = if (column == spanCount - 1) 0 else spacing
    // 底部间距:每行都有
    outRect.bottom = spacing
}

这样写,每个 item 的间距就均匀了。但注意,onDraw() 里也要对应地画线,否则只有间距没有分割线。

ItemAnimator:让增删动起来

ItemAnimator 负责 item 的添加、删除、移动、变化动画。RecyclerView 默认自带了一个 DefaultItemAnimator,效果是淡入淡出加位移。但说实话,这个默认动画太普通了,很多产品经理都看不上。

自定义 ItemAnimator 的核心是重写这几个方法:

  • animateAdd() — item 添加时的动画
  • animateRemove() — item 删除时的动画
  • animateMove() — item 移动时的动画
  • animateChange() — item 变化时的动画

每个方法返回一个 RecyclerView.ItemAnimator.ItemHolderInfo,里面记录了 item 的初始状态。然后你通过 ViewPropertyAnimator 或者 ObjectAnimator 来驱动动画。

一个缩放添加动画的例子

假设我们想让新添加的 item 从中心放大出现:

class ScaleInAnimator : SimpleItemAnimator() {

    override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean {
        // 记录动画前的状态
        dispatchAddStarting(holder)
        holder.itemView.scaleX = 0f
        holder.itemView.scaleY = 0f
        holder.itemView.alpha = 0f

        // 执行动画
        holder.itemView.animate()
            .scaleX(1f)
            .scaleY(1f)
            .alpha(1f)
            .setDuration(300)
            .setInterpolator(OvershootInterpolator())
            .withEndAction {
                dispatchAddFinished(holder)
            }
            .start()

        return true
    }

    override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
        dispatchRemoveStarting(holder)

        holder.itemView.animate()
            .scaleX(0f)
            .scaleY(0f)
            .alpha(0f)
            .setDuration(200)
            .withEndAction {
                dispatchRemoveFinished(holder)
                // 记得重置状态,否则复用时会出问题
                holder.itemView.scaleX = 1f
                holder.itemView.scaleY = 1f
                holder.itemView.alpha = 1f
            }
            .start()

        return true
    }
}
小技巧: 动画结束后一定要重置 View 的状态。我遇到过一个问题:item 被删除后复用,结果新 item 显示出来是缩小的。就是因为删除动画结束时没把 scale 重置回 1。

动画的协调与冲突

有时候,添加和删除同时发生。比如你删掉一个 item,同时插入另一个。这时候两个动画会同时跑,可能会出现闪烁或者位置错乱。

解决办法是:在 animateAdd()animateRemove() 里,用 dispatchAddFinished()dispatchRemoveFinished() 来通知 RecyclerView 动画结束。RecyclerView 会等所有动画结束后再重新布局。

另外,isRunning() 方法要返回当前是否有动画在执行。如果返回 false,RecyclerView 会认为动画已经结束,可能会提前触发布局。

SVG 知识结构图

下面这张图总结了 ItemDecoration 和 ItemAnimator 的核心关系:

RecyclerView 扩展机制 ItemDecoration 装饰绘制 getItemOffsets() → 设置偏移 onDraw() → 绘制在 item 下方 onDrawOver() → 绘制在 item 上方 典型场景: 分割线、间距、分组标签、水印 ItemAnimator 动画驱动 animateAdd() → 添加动画 animateRemove() → 删除动画 animateMove() → 移动动画 典型场景: 缩放、平移、淡入淡出、弹性效果 配合使用 ItemDecoration 负责“静态装饰”,ItemAnimator 负责“动态效果”

实战中的坑与建议

我总结几个实际项目中容易踩的坑:

  • 分割线被 item 遮挡:检查 getItemOffsets() 是否设置了足够的偏移。如果偏移为 0,画了也白画。
  • 动画结束后 View 状态异常:在 withEndAction 里重置 scale、alpha、translation 等属性。
  • 动画卡顿:不要在 onDraw() 里做耗时操作,比如创建对象、加载图片。Canvas 绘制要轻量。
  • 多类型 item 的分割线不一致:在 getItemOffsets() 里根据 getItemViewType() 做区分。

核心原则:

  • ItemDecoration 是“画布上的装饰”,不改变 item 本身
  • ItemAnimator 是“状态间的过渡”,要处理好开始和结束状态
  • 两者都依赖 RecyclerView 的布局机制,不要破坏原有的测量和布局流程

好了,关于 ItemDecoration 和 ItemAnimator 就聊这么多。这两个 API 看似简单,但用好了能让 RecyclerView 的视觉效果提升一个档次。下次你在项目里遇到“分割线带圆角”或者“删除时卡片飞走”的需求,应该知道怎么下手了吧?


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