13、RecyclerView的ItemDecoration与ItemAnimator:分割线绘制与增删动画的自定义
RecyclerView 的强大之处,不光在于它能高效回收 View。说实话,真正让 RecyclerView 变得灵活好用的,是它这套扩展机制。今天咱们就聊聊两个关键角色:ItemDecoration 和 ItemAnimator。
一个管“画”,一个管“动”。
我刚开始接触 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
}
}
动画的协调与冲突
有时候,添加和删除同时发生。比如你删掉一个 item,同时插入另一个。这时候两个动画会同时跑,可能会出现闪烁或者位置错乱。
解决办法是:在 animateAdd() 和 animateRemove() 里,用 dispatchAddFinished() 和 dispatchRemoveFinished() 来通知 RecyclerView 动画结束。RecyclerView 会等所有动画结束后再重新布局。
另外,isRunning() 方法要返回当前是否有动画在执行。如果返回 false,RecyclerView 会认为动画已经结束,可能会提前触发布局。
SVG 知识结构图
下面这张图总结了 ItemDecoration 和 ItemAnimator 的核心关系:
实战中的坑与建议
我总结几个实际项目中容易踩的坑:
- 分割线被 item 遮挡:检查
getItemOffsets()是否设置了足够的偏移。如果偏移为 0,画了也白画。 - 动画结束后 View 状态异常:在
withEndAction里重置 scale、alpha、translation 等属性。 - 动画卡顿:不要在
onDraw()里做耗时操作,比如创建对象、加载图片。Canvas 绘制要轻量。 - 多类型 item 的分割线不一致:在
getItemOffsets()里根据getItemViewType()做区分。
核心原则:
- ItemDecoration 是“画布上的装饰”,不改变 item 本身
- ItemAnimator 是“状态间的过渡”,要处理好开始和结束状态
- 两者都依赖 RecyclerView 的布局机制,不要破坏原有的测量和布局流程
好了,关于 ItemDecoration 和 ItemAnimator 就聊这么多。这两个 API 看似简单,但用好了能让 RecyclerView 的视觉效果提升一个档次。下次你在项目里遇到“分割线带圆角”或者“删除时卡片飞走”的需求,应该知道怎么下手了吧?