动画与 RecyclerView:ItemAnimator 自定义、添加/删除/移动动画、StaggeredGridLayoutManager 动画适配
RecyclerView 的动画,说实话,是很多 Android 开发者容易忽略但又极其影响体验的一环。你想想看,用户滑动列表时,突然插入一条数据,或者删除一条,如果只是生硬地闪一下,那感觉就像在吃火锅时咬到一颗沙子——难受。
我早期做社交应用时,就踩过这个坑。那时候列表里点赞、取消点赞需要实时更新,数据变了,列表项直接“啪”地一下刷新,用户反馈说“你这 App 怎么跟 PPT 一样,一页一页翻的?” 嗯,从那以后,我就开始认真研究 ItemAnimator 了。
ItemAnimator 是什么?
说白了,ItemAnimator 就是 RecyclerView 用来处理 item 添加、删除、移动、变化时动画效果的引擎。RecyclerView 默认自带一个 DefaultItemAnimator,能实现简单的淡入淡出和位移。但如果你想要更炫酷的效果,比如卡片翻转、缩放、或者像 iOS 那种弹性插入,那就得自己动手了。
它的核心原理其实不复杂:
- 当数据变化时,RecyclerView 会对比新旧数据集,计算出哪些 item 是新增的、删除的、移动的、还是仅仅内容变了。
- 然后把这些信息封装成
ViewHolder的动画状态,交给 ItemAnimator 去执行。 - ItemAnimator 负责处理动画的
animateAdd、animateRemove、animateMove、animateChange四个方法。
核心要点: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 以内比较合适,否则用户会觉得“这列表怎么弹来弹去的”。
小技巧:如果你想让动画更自然,可以结合 DecelerateInterpolator 和 OvershootInterpolator 混用。我个人习惯是:新增用 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 时,瀑布流列表的删除动画简直让我崩溃。后来我总结了几条经验:
- 不要用默认的 ItemAnimator,至少重写
animateMove方法,手动计算目标位置。 - 移动动画的位移计算:在
animateMove中,通过holder.itemView.translationX/Y来设置偏移,而不是直接修改 layoutParams。 - 动画时长要适当延长,瀑布流 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 动画这件事,说难不难,说简单也不简单。核心就三点:
- 数据差异计算:用 DiffUtil 或 ListAdapter,别偷懒用 notifyDataSetChanged。
- 动画逻辑实现:重写 ItemAnimator 的四个方法,注意 dispatch 回调的调用。
- 特殊布局适配:StaggeredGridLayoutManager 下,移动动画要手动计算位移,别指望默认实现。
我个人觉得,动画是 UI 体验的“最后一公里”。用户可能说不清哪里好,但一旦动画卡顿或缺失,他们一定能感觉到。所以,花点时间把 ItemAnimator 调好,绝对值。
最后一个小建议:如果你实在不想自己写 ItemAnimator,可以试试开源库 recyclerview-animators,它提供了很多现成的动画效果。但记住,库只是工具,理解原理才能应对各种奇葩需求。