自定义 RecyclerView 动画:手写 ItemAnimator 实现 Remove 飞出效果

QuibblerQuibbler 2020-01-03 约 24 分钟 196 次阅读

自定义 RecyclerView 动画:手写 ItemAnimator 实现 Remove 飞出效果

列表里删一条数据时,我们常常希望它"飞出去"、后面的项平滑上移填补空位——这正是 RecyclerView 的 ItemAnimator 负责的事。但很多人照着写会发现:要么完全没动画,要么被删项飞出时下方 item 瞬间跳上去、和飞出项重叠闪烁。问题不在动画本身,而在于没理解 ItemAnimator "延迟批量执行"的调度机制,也没处理好 remove 与 move 两个动作的配合。本文先拆解系统默认 DefaultItemAnimator 的调度原理(参考 Piasy 的 ItemAnimator 深入解析 与 腾讯云原文),再手写一个 FlyAnimator,把 remove 飞出效果完整实现出来。

1、ItemAnimator 是什么

当 Adapter 数据变化(如 notifyItemRemoved)后,ItemAnimator 负责播放对应的增、删、改、移动画。真正落地的是抽象类 SimpleItemAnimator,它对外暴露 4 个"动作"回调和 4 个"调度/生命周期"方法:

// import androidx.recyclerview.widget.RecyclerView;  下文 ViewHolder 即 RecyclerView.ViewHolder

// 4 个动作回调:返回 true 表示"我要播动画,先别动 view,等统一调度"
boolean animateRemove(ViewHolder holder)                                          // 移除
boolean animateAdd(ViewHolder holder)                                             // 新增
boolean animateMove(ViewHolder holder, int fromX, int fromY, int toX, int toY)    // 位移
boolean animateChange(ViewHolder oldH, ViewHolder newH, int fl,int ft,int tl,int tt) // 变化

// 调度 / 生命周期
void runPendingAnimations()     // 统一启动被"占位"的动画
void endAnimation(ViewHolder)   // 结束某个 item 的动画
void endAnimations()            // 结束所有动画
boolean isRunning()             // 是否还有动画在跑

核心实现(回调约定):

1. animateXxx 只负责"收集",真正播放放在 runPendingAnimations,这是后面所有写法的基石

2. 返回 true 后,RecyclerView 不会立即把 view 移除或复位,而是等待动画结束回调再清理

3. 动画开始/结束必须调用 dispatchRemoveStarting / dispatchRemoveFinished 等回调,否则 RecyclerView 的状态机会错乱(item 显示异常、复用错位)

4. 全部动画结束后调用 dispatchAnimationsFinished() 收尾

2、DefaultItemAnimator 的调度原理

DefaultItemAnimator 是 RecyclerView 默认的 Animator,源码不长但极值得读,它的设计核心是四个字:延迟批量执行。

// 1. animateRemove:只入队,不执行
@Override
public boolean animateRemove(ViewHolder holder) {
    mPendingRemoves.add(holder);
    return true;   // 告诉 RecyclerView:先别移除 view,等我动画
}

// 2. animateMove:先用 translation 把 view "钉"在旧位置,再入队
@Override
public boolean animateMove(ViewHolder holder, int fromX, int fromY, int toX, int toY) {
    View view = holder.itemView;
    view.setTranslationX(fromX - toX);   // 钉回旧位置,避免瞬间跳到新位置
    view.setTranslationY(fromY - toY);
    mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY));
    return true;
}

// 3. runPendingAnimations:批量调度,顺序 remove -> move -> change -> add
@Override
public void runPendingAnimations() {
    // 先启动所有 remove(120ms 淡出,最该先消失)
    // 接着启动 move(其余 item 上移填补空位)
    // 再启动 change,最后延迟启动 add(等位置腾出来)
}

// remove 真正的动画:alpha 1 -> 0
ViewCompat.animate(view).alpha(0).setDuration(120L).start();

核心实现(机制要点):

1. animateRemove 只把 holder add 进 mPendingRemoves 队列并 return true,绝不直接开动画

2. runPendingAnimations 统一取出 4 个 pending 队列逐个启动,顺序固定:remove 先于 move

3. move 用"钉旧位 + 归零"实现平滑过渡——这正是消除"瞬间跳变"的关键

4. 四个动画默认时长都是 120ms(CHANGE_DURATION / MOVE_DURATION / REMOVE_DURATION / ADD_DURATION)

- 结论:模仿这套"收集 + 批量 + 分工"结构,自定义动画就不会乱

3、动手:FlyAnimator 骨架

目标只做 remove(飞出)和 move(上移),add / change 不需要就"直接结束并 return false"。仿 DefaultItemAnimator,为 remove 和 move 各准备两个 list:一个收集 pending,一个记录运行中。

public class FlyAnimator extends SimpleItemAnimator {

    // remove:待执行 + 运行中
    private final List<ViewHolder> mPendingRemoves = new ArrayList<>();
    private final List<ViewHolder> mRemoveAnims    = new ArrayList<>();

    // move:待执行 + 运行中
    private final List<ViewHolder> mPendingMoves = new ArrayList<>();
    private final List<ViewHolder> mMoveAnims    = new ArrayList<>();

    @Override public boolean animateRemove(ViewHolder holder) { /* 见第 4 节 */ return true; }
    @Override public boolean animateMove(ViewHolder h, int fx,int fy,int tx,int ty) { /* 见第 5 节 */ return true; }
    @Override public void runPendingAnimations() { /* 见第 4 节 */ }

    // 不需要的动作:直接 dispatchXxxFinished 后 return false,告诉 RecyclerView 跳过
    @Override public boolean animateAdd(ViewHolder h) {
        dispatchAddFinished(h);
        return false;
    }
    @Override public boolean animateChange(ViewHolder o, ViewHolder n, int fl,int ft,int tl,int tt) {
        dispatchChangeFinished(n, false);
        return false;
    }

    @Override public void endAnimation(ViewHolder item) { }
    @Override public void endAnimations() { }

    // isRunning 必须综合 4 个 list,全部为空才算动画结束
    @Override public boolean isRunning() {
        return !(mPendingRemoves.isEmpty() && mRemoveAnims.isEmpty()
              && mPendingMoves.isEmpty() && mMoveAnims.isEmpty());
    }
}

核心实现:

1. 继承 SimpleItemAnimator,4 个 animateXxx + runPendingAnimations + endXxx + isRunning 都要实现

2. 用两组 list分别管理 remove 与 move 的"待执行"和"运行中"状态

3. 不需要的动作直接 dispatchXxxFinished 后 return false,避免 RecyclerView 干等

4. isRunning 综合全部队列——它是 RecyclerView 判断"能否安全刷新"的依据,写漏会导致后续动画错乱

4、实现 remove 飞出动画

按 DefaultItemAnimator 的套路:animateRemove 只收集,runPendingAnimations 统一启动。remove() 用补间动画 TranslateAnimation 让 itemView 向右飞出。

// ① 收集:只入队,不执行
@Override
public boolean animateRemove(ViewHolder holder) {
    mPendingRemoves.add(holder);
    return true;
}

// ② 调度:批量启动
@Override
public void runPendingAnimations() {
    if (!mPendingRemoves.isEmpty()) {
        for (ViewHolder holder : mPendingRemoves) {
            remove(holder);
        }
        mPendingRemoves.clear();
    }
    if (!mPendingMoves.isEmpty()) {            // move 部分见第 5 节
        for (ViewHolder holder : mPendingMoves) {
            move(holder);
        }
        mPendingMoves.clear();
    }
}

// ③ 执行:向右平移 1000px,模拟飞出屏幕
private void remove(final ViewHolder holder) {
    mRemoveAnims.add(holder);
    TranslateAnimation anim = new TranslateAnimation(0, 1000, 0, 0);
    anim.setDuration(500);
    anim.setAnimationListener(new Animation.AnimationListener() {
        @Override public void onAnimationStart(Animation a) {
            dispatchRemoveStarting(holder);    // 必须:通知动画开始
        }
        @Override public void onAnimationEnd(Animation a) {
            mRemoveAnims.remove(holder);
            holder.itemView.clearAnimation();
            dispatchRemoveFinished(holder);    // 必须:通知动画结束
            if (!isRunning()) {
                dispatchAnimationsFinished();  // 全部结束,收尾
            }
        }
        @Override public void onAnimationRepeat(Animation a) { }
    });
    holder.itemView.startAnimation(anim);
}

核心实现:

1. animateRemove 把 holder 加入 mPendingRemoves 并 return true,真正动画延迟到 runPendingAnimations

2. runPendingAnimations 遍历 mPendingRemoves 逐个 remove(),结束后 clear,与 DefaultItemAnimator 完全一致

3. remove() 用 TranslateAnimation 让 itemView 向右飞出 1000px,duration 500ms

4. dispatchRemoveStarting / dispatchRemoveFinished 必须配对调用,否则 RecyclerView 误以为 view 还在动画、不予复用,导致列表卡死或错位

5. 每个 remove 结束都检查 isRunning(),为 false 才 dispatchAnimationsFinished() 收尾

5、修复"下方瞬间上移"的重叠闪烁

只做上一节的 remove 飞出,运行后会看到:被删项飞出时,下方 item 已经瞬间上移,和飞出项重叠闪烁。原因正如第 2 节所说——删除瞬间 RecyclerView 已重新布局,下方 item 跳到了新位置,而我们缺少 move 动画把它"钉回旧位再平滑滑回"。

// ① 钉回旧位置:删除瞬间下方 item 已跳到新位置,用 translationY 钉回旧视觉位
@Override
public boolean animateMove(ViewHolder holder, int fromX, int fromY, int toX, int toY) {
    holder.itemView.setTranslationY(fromY - toY);
    mPendingMoves.add(holder);
    return true;
}

// ② 平滑上移归位:translationY 从当前值动画到 0
private void move(final ViewHolder holder) {
    mMoveAnims.add(holder);
    final View view = holder.itemView;
    ObjectAnimator animator = ObjectAnimator.ofFloat(
            view, "translationY", view.getTranslationY(), 0f);
    animator.setDuration(500);
    animator.addListener(new AnimatorListenerAdapter() {
        @Override public void onAnimationStart(Animator a) {
            dispatchMoveStarting(holder);
        }
        @Override public void onAnimationEnd(Animator a) {
            mMoveAnims.remove(holder);
            dispatchMoveFinished(holder);
            if (!isRunning()) {
                dispatchAnimationsFinished();
            }
        }
    });
    animator.start();
}

核心实现:

1. animateMove 用 setTranslationY(fromY - toY) 把已上跳的 item 钉回原视觉位置,这是消除闪烁的第一步

2. move() 用 ObjectAnimator 把 translationY 平滑归零,呈现"上移填补空位"的效果

3. remove 飞出 + move 上移同时进行(都在 runPendingAnimations 里启动),视觉上就是"一条飞走、后面的跟上来"

4. dispatchMoveStarting / dispatchMoveFinished 同样不可省,否则 RecyclerView 误以为 item 还没就位

- 至此 remove + move 配合完成,重叠闪烁消失,飞出效果丝滑

6、设置使用与避坑

写好 Animator,三行就能用上;但有几个坑要避开:

// 1. 设置自定义 Animator
recyclerView.setItemAnimator(new FlyAnimator());

// 2. 可选:关闭 change 的交叉淡入淡出,减少列表闪烁(常用优化)
((SimpleItemAnimator) recyclerView.getItemAnimator())
        .setSupportsChangeAnimations(false);

// 3. 删除数据时务必用 notifyItemRemoved,才会触发 remove + move 动画
list.remove(position);
adapter.notifyItemRemoved(position);
// 若删除导致后续多项位置变化,配合 notifyItemRangeChanged 让 move 动画覆盖到位
adapter.notifyItemRangeChanged(position, list.size() - position);

核心实现(避坑要点):

1. notifyDataSetChanged() 不会触发逐项动画,必须用 notifyItemRemoved / notifyItemRangeChanged 等带位置信息的方法

2. setSupportsChangeAnimations(false) 关闭 change 时"新旧两个 item 交叉淡入淡出"的默认行为,是消除列表闪烁的常用手段

3. 补间动画(TranslateAnimation)只改绘制、不改 view 真实位置——飞出场景因 view 即将被回收影响较小,但若该 view 会被复用,务必在 onAnimationEnd 里 clearAnimation()

4. 想更稳妥可用属性动画(ObjectAnimator / ViewCompat.animate)做飞出,从根上避免点击位置错位

- 默认动画时长 120ms 偏短,自定义时按需调大(本文示例用 500ms 效果更明显)

7、总结

列表删除的"飞出 + 上移"不是两个独立动画,而是 ItemAnimator 的 remove 与 move 两个动作的配合。理解了 DefaultItemAnimator 的"收集 + 批量调度",自定义就只剩"把动画填进对应动作"这一步。

关键要点:

- animateXxx 只入队、return true,真正动画放 runPendingAnimations,这是 ItemAnimator 的通用写法

- dispatchRemoveStarting/Finished、dispatchMoveStarting/Finished 必须配对调用,否则 RecyclerView 状态错乱

- isRunning 要综合所有队列,全空才算动画结束

- 消除"下方瞬间上移"靠 move 的"钉旧位(setTranslationY)+ 平滑归零"

- 用 notifyItemRemoved 触发,notifyDataSetChanged 不触发逐项动画;补间动画记得 clearAnimation

对于做列表交互、想给删除/拖拽加自定义动效的开发者而言,掌握 ItemAnimator 的"收集 + 批量 + 分工"三段式写法是必要的——它不只是"写个动画",更是与 RecyclerView 复用机制正确协作的契约。把 remove 与 move 这对搭档写好,再复杂的列表动画都能在这个骨架上扩展出来。

相关推荐

精选
ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager
Android

ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager

ViewPager1、ViewPagerandroidx.viewpager.widget.ViewPager,Android中使用非常广泛的控件,可以说是APP必备:首次打开引导页、页面Banner广告等。常用方法:setAdapter() 设置适配器setOffscreenPageLimit() 设置缓存的页面个数,默认是 1setCurrentItem() 跳转到特定的页面setOnPage

1.1k
获取Android内置WebView内核版本
Android

获取Android内置WebView内核版本

获取Android内置WebView内核版本竟然能遇到这样奇葩的事情,网页用的技术过于新颖,以至于只支持高版本Chromium内核的,低版本安卓系统中内置的内核版本较低,无法加载前端页面。1、设置查看在系统设置里 > 应用 > 应用管理 > 显示系统应用,查看WebView组件:2、页面查看通过WebView发起的网络请求,都会带上浏览器的UA,通常页面都可以通过UA判断浏览器的内核版本。这里有两

1.1w
Android 14适配总结
Android

Android 14适配总结

Android 14适配总结毕业工作至今已经适配了三个Android大版本,从Android 11到Android 12、再到Android 13。2023年,Google即将推出的Android 14,上半年已经开始第一批适配。现在,第四个Android版本已经适配完,总结记录一下。1、Android 14计划Google一般会在2月份对外发布预告,同时放出开发者预览版。“拉通”各大平台、厂商以

1.1w