动画与 RecyclerView:ItemAnimator 自定义、添加/删除/移动动画、StaggeredGridLayoutManager 动画适配

RecyclerView 的动画,说实话,是很多 Android 开发者容易忽略但又极其影响体验的一环。你想想看,用户滑动列表时,突然插入一条数据,或者删除一条,如果只是生硬地闪一下,那感觉就像在吃火锅时咬到一颗沙子——难受。

我早期做社交应用时,就踩过这个坑。那时候列表里点赞、取消点赞需要实时更新,数据变了,列表项直接“啪”地一下刷新,用户反馈说“你这 App 怎么跟 PPT 一样,一页一页翻的?” 嗯,从那以后,我就开始认真研究 ItemAnimator 了。

ItemAnimator 是什么?

说白了,ItemAnimator 就是 RecyclerView 用来处理 item 添加、删除、移动、变化时动画效果的引擎。RecyclerView 默认自带一个 DefaultItemAnimator,能实现简单的淡入淡出和位移。但如果你想要更炫酷的效果,比如卡片翻转、缩放、或者像 iOS 那种弹性插入,那就得自己动手了。

它的核心原理其实不复杂:

  • 当数据变化时,RecyclerView 会对比新旧数据集,计算出哪些 item 是新增的、删除的、移动的、还是仅仅内容变了。
  • 然后把这些信息封装成 ViewHolder 的动画状态,交给 ItemAnimator 去执行。
  • ItemAnimator 负责处理动画的 animateAddanimateRemoveanimateMoveanimateChange 四个方法。

核心要点:ItemAnimator 不关心数据怎么来的,它只关心 ViewHolder 怎么动。你只需要告诉它“这个 item 从哪开始,到哪结束”,剩下的就是动画插值的事了。

自定义 ItemAnimator:从零开始写一个缩放动画

我记得有一次项目需求是:列表新增 item 时,从屏幕中心放大出现,删除时缩小消失。默认的 ItemAnimator 显然做不到,于是我写了一个简单的 ScaleInItemAnimator

先看代码:

class ScaleInItemAnimator : DefaultItemAnimator() {

    override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean {
        // 重置动画状态
        holder.itemView.alpha = 0f
        holder.itemView.scaleX = 0f
        holder.itemView.scaleY = 0f
        // 通知 RecyclerView 开始动画
        dispatchAddStarting(holder)
        return true
    }

    override fun animateAddImpl(holder: RecyclerView.ViewHolder) {
        val view = holder.itemView
        view.animate()
            .alpha(1f)
            .scaleX(1f)
            .scaleY(1f)
            .setDuration(300)
            .setInterpolator(OvershootInterpolator())
            .withEndAction {
                dispatchAddFinished(holder)
            }
            .start()
    }

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

    override fun animateRemoveImpl(holder: RecyclerView.ViewHolder) {
        val view = holder.itemView
        view.animate()
            .alpha(0f)
            .scaleX(0f)
            .scaleY(0f)
            .setDuration(200)
            .withEndAction {
                dispatchRemoveFinished(holder)
            }
            .start()
    }
}

这里有几个坑,我当初都踩过:

  • 必须调用 dispatchAddStarting / dispatchAddFinished,否则 RecyclerView 会认为动画没结束,导致 item 一直处于“动画中”状态,无法正常复用。
  • 动画结束后要清理,比如 view.animate().setListener(null),否则可能内存泄漏。
  • OvershootInterpolator 很爽,但别用太久,300ms 以内比较合适,否则用户会觉得“这列表怎么弹来弹去的”。

小技巧:如果你想让动画更自然,可以结合 DecelerateInterpolatorOvershootInterpolator 混用。我个人习惯是:新增用 Overshoot,删除用 Decelerate,这样视觉上“进来有弹性,出去很干脆”。

添加/删除/移动动画:DiffUtil 是关键

很多人以为 ItemAnimator 只管动画,其实它依赖一个重要的前置步骤——数据差异计算。没有 DiffUtil,RecyclerView 根本不知道哪些 item 是新增、删除还是移动的。

我见过不少项目,为了省事,直接 notifyDataSetChanged(),然后 ItemAnimator 就废了。为什么?因为 RecyclerView 认为所有 item 都是“变化”的,不会触发 add/remove/move 动画,只会触发 change 动画。

正确的做法是:

val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
    override fun getOldListSize() = oldList.size
    override fun getNewListSize() = newList.size

    override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
        return oldList[oldPos].id == newList[newPos].id
    }

    override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
        return oldList[oldPos] == newList[newPos]
    }
})

diffResult.dispatchUpdatesTo(adapter)
// 或者使用 ListAdapter,它会自动处理

DiffUtil 会帮你计算出:

  • 哪些 item 是新增的(oldList 没有,newList 有)
  • 哪些是删除的(oldList 有,newList 没有)
  • 哪些是移动的(位置变了,但 id 相同)
  • 哪些是变化的(id 相同,但内容变了)

然后 RecyclerView 会根据这些信息,自动调用 ItemAnimator 对应的动画方法。你只需要写好动画逻辑,剩下的交给框架。

注意:如果你用了 notifyItemInserted()notifyItemRemoved() 手动通知,一定要保证位置正确。我曾经因为索引计算错误,导致动画错位——删除第 3 个 item,结果第 5 个 item 飞出去了。排查了半天,发现是列表数据更新时索引没同步。

StaggeredGridLayoutManager 动画适配:没那么简单

StaggeredGridLayoutManager(瀑布流布局)的动画适配,是很多人的噩梦。为什么?因为它的 item 高度不一致,移动动画的位移计算比 LinearLayoutManager 复杂得多。

默认的 DefaultItemAnimator 在瀑布流下经常出现“鬼畜”现象:

  • item 移动时,位置计算错误,导致动画路径扭曲
  • 新增 item 时,其他 item 的位移动画不流畅
  • 删除 item 时,后面的 item 会“跳”一下才归位

我当初做图片社区 App 时,瀑布流列表的删除动画简直让我崩溃。后来我总结了几条经验:

  1. 不要用默认的 ItemAnimator,至少重写 animateMove 方法,手动计算目标位置。
  2. 移动动画的位移计算:在 animateMove 中,通过 holder.itemView.translationX/Y 来设置偏移,而不是直接修改 layoutParams。
  3. 动画时长要适当延长,瀑布流 item 高度差异大,动画时间太短会显得很突兀,我一般设 400ms。

这里给一个简单的瀑布流移动动画适配示例:

override fun animateMove(
    holder: RecyclerView.ViewHolder,
    fromX: Int, fromY: Int,
    toX: Int, toY: Int
): Boolean {
    val view = holder.itemView
    val deltaX = fromX - toX
    val deltaY = fromY - toY

    view.translationX = deltaX.toFloat()
    view.translationY = deltaY.toFloat()

    view.animate()
        .translationX(0f)
        .translationY(0f)
        .setDuration(400)
        .setInterpolator(DecelerateInterpolator())
        .withEndAction {
            dispatchMoveFinished(holder)
        }
        .start()

    return true
}

核心思路就是:先把 View 挪到“旧位置”,然后动画回到“新位置”。这样 RecyclerView 在布局时已经把所有 item 放到了正确位置,我们只需要做视觉上的位移欺骗。

避坑指南:我曾经在瀑布流里用 animateChange 做内容更新动画,结果发现 item 的高度变化会导致其他 item 的布局重新计算,动画路径完全乱掉。后来我改用 animateMove 配合 notifyItemChanged 的 payload 机制,才解决了这个问题。

知识体系总览

下面这张图,是我整理的本章节核心逻辑。你可以看到,ItemAnimator 只是冰山一角,真正支撑它的是 DiffUtil 的数据差异计算和 LayoutManager 的布局策略。

RecyclerView 动画知识体系 RecyclerView LayoutManager ItemAnimator DiffUtil 布局信息 差异结果 animateAdd animateRemove animateMove animateChange StaggeredGrid 适配 自定义动画效果 核心:数据差异 → 布局计算 → 动画执行

总结一下

RecyclerView 动画这件事,说难不难,说简单也不简单。核心就三点:

  • 数据差异计算:用 DiffUtil 或 ListAdapter,别偷懒用 notifyDataSetChanged。
  • 动画逻辑实现:重写 ItemAnimator 的四个方法,注意 dispatch 回调的调用。
  • 特殊布局适配:StaggeredGridLayoutManager 下,移动动画要手动计算位移,别指望默认实现。

我个人觉得,动画是 UI 体验的“最后一公里”。用户可能说不清哪里好,但一旦动画卡顿或缺失,他们一定能感觉到。所以,花点时间把 ItemAnimator 调好,绝对值。

最后一个小建议:如果你实在不想自己写 ItemAnimator,可以试试开源库 recyclerview-animators,它提供了很多现成的动画效果。但记住,库只是工具,理解原理才能应对各种奇葩需求。


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