老大爷都能看懂的RecyclerView动画原理

孤人 提交于 2020-12-30 21:05:55

如何阅读本篇文章

本文主要讲解RecyclerView Layout变化触发动画执行的原理。前半部分偏重原理和代码的讲解,后半部分通过图文结合场景讲解各个阶段的执行过程。

建议先粗略阅读前半部分的原理和代码篇,做到心中有概念,带着理论知识去阅读后半部分的场景篇。最后结合全文学到的知识,带着问题去阅读源码,效果会更好。

原理篇

1. Adapter的notify方法

用过RecyclerView的同学大概都应该知道Adapter有几个notify相关的方法,它们分别是:

  • notifyDataSetChanged()
  • notifyItemChanged(int)
  • notifyItemInserted(int)
  • notifyItemRemoved(int)
  • notifyItemRangeChanged(int, int)
  • notifyItemRangeInserted(int, int)
  • notifyItemRangeRemoved(int, int)
  • notifyItemMoved(int, int)

稍微有点开发经验的同学都知道,notifyDataSetChanged()方法比其它的几个方法更重量级一点,它会导致整个列表刷新,其它几个方法则不会。有更多开发经验的同学可能还知道notifyDataSetChanged()方法不会触发RecyclerView的动画机制,其它几个方法则会触发各种不同类型的动画。

2. RecyclerView的布局逻辑

2.1 RecyclerView的dispatchLayout

dispatchLayout顾名思义,当然是把子View布局(添加并放置到合适的位置)到RecyclerView上面了。打开它的源码我们可以看到这样一段注释。

Wrapper around layoutChildren() that handles animating changes caused by layout.  Animations work on the assumption that there are five different kinds of items in play:

  1. PERSISTENT: items are visible before and after layout

  2. REMOVED: items were visible before layout and were removed by the app

  3. ADDED: items did not exist before layout and were added by the app

  4. DISAPPEARING: items exist in the data set before/after, but changed from visible to non-visible in the process of layout (they were moved off screen as a side-effect of other changes)

  5. APPEARING: items exist in the data set before/after, but changed from non-visible to visible in the process of layout (they were moved on  screen as a side-effect of other changes)

从注释我们可以知道。dispatchLayout方法不仅有给子View布局的功能,而且可以处理动画。动画主要分为五种:

  1. PERSISTENT:针对布局前和布局后都在手机界面上的View所做的动画
  2. REMOVED:在布局前对用户可见,但是数据已经从数据源中删除掉了
  3. ADDED:新增数据到数据源中,并且在布局后对用户可见
  4. DISAPPEARING:数据一直都存在于数据源中,但是布局后从可见变成不可见状态
  5. APPEARING:数据一直都存在于数据源中,但是布局后从不可见变成可见状态

到目前为止,我们还不能完全理解这五种类型的动画有什么具体的区别,分别在什么样的场景下会触发这些类型的动画。但是给我们提供了很好的研究思路。目前我们只需要简单了解有这五种动画,接着往下,我们这里看下dispatchLayout的源码,为了响应文章标题,这里贴出精简过的源码:

void dispatchLayout(){
  ...
  dispatchLayoutStep1();
  dispatchLayoutStep2();
  dispatchLayoutStep3();
  ...
}

关于dispatchLayoutStepX方法,相信很多人都听说或者了解过,文章后面我会做详细的介绍,简单介绍如下:

从dispatchLayout的注释中,我们注意到before和after两个单词,分别表示布局前和布局后。这么说来那就简单了。dispatchLayoutStep1对应的是before(布局前),dispatchLayoutStep2的意思是布局中,dispatchLayoutStep3对应的是after(布局后)。它们的作用描述如下:

  1. dispatchLayoutStep1
    1. 判断是否需要开启动画功能

    2. 如果开启动画,将当前屏幕上的Item相关信息保存起来供后续动画使用

    3. 如果开启动画,调用mLayout.onLayoutChildren方法预布局

    4. 预布局后,与第二步保存的信息对比,将新出现的Item信息保存到Appeared中

精简后的代码如下:

private void dispatchLayoutStep1() {
  ...
  //第一步 判断是否需要开启动画功能
  processAdapterUpdatesAndSetAnimationFlags();
  ...
  if (mState.mRunSimpleAnimations) {
    ...
    //第二步  将当前屏幕上的Item相关信息保存起来供后续动画使用
    int count = mChildHelper.getChildCount();
    for (int i = 0; i < count; ++i) {
        final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
        final ItemHolderInfo animationInfo = mItemAnimator
                        .recordPreLayoutInformation(mState, holder,
                                ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
                                holder.getUnmodifiedPayloads());
        mViewInfoStore.addToPreLayout(holder, animationInfo);
    }
    ...
    if (mState.mRunPredictiveAnimations) {
          saveOldPositions();
          //第三步 调用onLayoutChildren方法预布局
          mLayout.onLayoutChildren(mRecycler, mState);
          mState.mStructureChanged = didStructureChange;

          for (int i = 0; i < mChildHelper.getChildCount(); ++i) {
              final View child = mChildHelper.getChildAt(i);
              final ViewHolder viewHolder = getChildViewHolderInt(child);
              if (viewHolder.shouldIgnore()) {
                  continue;
              }
                        //第四步 预布局后,对比预布局前后,哪些item需要放入到Appeared中

              if (!mViewInfoStore.isInPreLayout(viewHolder)) {

                  if (wasHidden) {
                      recordAnimationInfoIfBouncedHiddenView(viewHolder, animationInfo);
                  } else {
                      mViewInfoStore.addToAppearedInPreLayoutHolders(viewHolder, animationInfo);
                  }
              }
          }
          clearOldPositions();
      } else {
          clearOldPositions();
      }
  }

}
  1. dispatchLayoutStep2 根据数据源中的数据进行布局,真正展示给用户看的最终界面
private void dispatchLayoutStep2() {
    ...
    // Step 2: Run layout
    mState.mInPreLayout = false;//此处关闭预布局模式
    mLayout.onLayoutChildren(mRecycler, mState);
    ...
}
  1. dispatchLayoutStep3 触发动画
private void dispatchLayoutStep3() {
    ...
    if (mState.mRunSimpleAnimations) {
        // Step 3: Find out where things are now, and process change animations.
        // traverse list in reverse because we may call animateChange in the loop which may
        // remove the target view holder.
        for (int i = mChildHelper.getChildCount() - 1; i >= 0; i--) {
            ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
            if (holder.shouldIgnore()) {
                continue;
            }
            long key = getChangedHolderKey(holder);
            final ItemHolderInfo animationInfo = mItemAnimator
                    .recordPostLayoutInformation(mState, holder);
            ViewHolder oldChangeViewHolder = mViewInfoStore.getFromOldChangeHolders(key);
            if (oldChangeViewHolder != null && !oldChangeViewHolder.shouldIgnore()) {
                // run a change animation
                ...
            } else {
                mViewInfoStore.addToPostLayout(holder, animationInfo);
            }
        }

        // Step 4: Process view info lists and trigger animations
        //触发动画
        mViewInfoStore.process(mViewInfoProcessCallback);
    }

  ...
    }

从代码我们可以看出dispatchLayoutStep1和dispatchLayoutStep2方法中调用了onLayoutChildren方法,而dispatchLayoutStep3没有调用。

2.2 LinearLayoutManager的onLayoutChildren方法

以垂直方向的RecyclerView为例子,我们填充RecyclerView的方向有两种,从上往下填充和从下往上填充。开始填充的位置不是固定的,可以从RecyclerView的任意位置处开始填充。该方法的功能我精简为以下几个步骤:

  1. 寻找填充的锚点(最终调用findReferenceChild方法)
  2. 移除屏幕上的Views(最终调用detachAndScrapAttachedViews方法)
  3. 从锚点处从上往下填充(调用fill和layoutChunk方法)
  4. 从锚点处从下往上填充(调用fill和layoutChunk方法)
  5. 如果还有多余的空间,继续填充(调用fill和layoutChunk方法)
  6. 非预布局,将scrapList中多余的ViewHolder填充(调用layoutForPredictiveAnimations)

本文只讲解onLayoutChildren的主流程,具体的填充逻辑请参考RecyclerView填充逻辑一文

LinearLayoutManager#onLayoutChildren

  public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    ...
    //1. 寻找填充的锚点
    updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
    
    ...
    //2. 移除屏幕上的Views
    detachAndScrapAttachedViews(recycler);
    
    ...
    //3. 从锚点处从上往下填充
    updateLayoutStateToFillEnd(mAnchorInfo);
    mLayoutState.mExtraFillSpace = extraForEnd;
    fill(recycler, mLayoutState, state, false);
    
    ...
    //4. 从锚点处从下往上填充
    // fill towards start
    updateLayoutStateToFillStart(mAnchorInfo);
    mLayoutState.mExtraFillSpace = extraForStart;
    mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
    fill(recycler, mLayoutState, state, false);
    
    ...
    //5. 如果还有多余的空间,继续填充
    if (mLayoutState.mAvailable > 0) {
        extraForEnd = mLayoutState.mAvailable;
        // start could not consume all it should. add more items towards end
        updateLayoutStateToFillEnd(lastElement, endOffset);
        mLayoutState.mExtraFillSpace = extraForEnd;
        fill(recycler, mLayoutState, state, false);
        endOffset = mLayoutState.mOffset;
    }
  }
    ...
    //6. 非预布局,将scrapList中多余的ViewHolder填充
    layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
    ...

LinearLayoutManager#layoutForPredictiveAnimations

 private void layoutForPredictiveAnimations(RecyclerView.Recycler recycler,
            RecyclerView.State state, int startOffset,
            int endOffset) {
        //判断是否满足条件,如果是预布局直接返回
        if (!state.willRunPredictiveAnimations() ||  getChildCount() == 0 || state.isPreLayout()
                || !supportsPredictiveItemAnimations()) {
            return;
        }
        // 遍历scrapList,步骤2中屏幕中被移除的View
        int scrapExtraStart = 0, scrapExtraEnd = 0;
        final List<RecyclerView.ViewHolder> scrapList = recycler.getScrapList();
        final int scrapSize = scrapList.size();
        final int firstChildPos = getPosition(getChildAt(0));
        for (int i = 0; i < scrapSize; i++) {
            RecyclerView.ViewHolder scrap = scrapList.get(i);
            //如果被remove掉了,跳过
            if (scrap.isRemoved()) {
                continue;
            }
            //计算额外的控件
                scrapExtraEnd += mOrientationHelper.getDecoratedMeasurement(scrap.itemView);

        }

        mLayoutState.mScrapList = scrapList;
        ...
        // 步骤6 继续填充
        if (scrapExtraEnd > 0) {
            View anchor = getChildClosestToEnd();
            updateLayoutStateToFillEnd(getPosition(anchor), endOffset);
            mLayoutState.mExtraFillSpace = scrapExtraEnd;
            mLayoutState.mAvailable = 0;
            mLayoutState.assignPositionFromScrapList();
            fill(recycler, mLayoutState, state, false);
        }
        mLayoutState.mScrapList = null;
    }

至此,布局的逻辑已经讲解完毕。关于具体的动画执行逻辑,由于篇幅有限。不在本文中讲解

场景篇

1. notifyItemRemoved

我们来测试从屏幕中删除View,调用notifyItemRemoved相关的方法,dispatchLayout是如何重新布局的。假设初始状态如下图,假设Adapter数据有100条,屏幕上有Item1~Item6 6个View,删除Item1和Item2。

  1. 将Item1 Item2对应的ViewHolder设置为REMOVE状态
  2. 将所有的Item对应的ViewHolder的mPreLayoutPosition字段赋值为当前的position

我们回顾一下onLayoutChildren的几个步骤

  1. 寻找填充的锚点(最终调用findReferenceChild方法)
  2. 移除屏幕上的Views(最终调用detachAndScrapAttachedViews方法)
  3. 从锚点处从上往下填充(调用fill和layoutChunk方法)
  4. 从锚点处从下往上填充(调用fill和layoutChunk方法)
  5. 如果还有多余的空间,继续填充(调用fill和layoutChunk方法)
  6. 非预布局,将scrapList中多余的ViewHolder填充(调用layoutForPredictiveAnimations)

1.1 dispatchLayoutStep1阶段

  1. 寻找填充的锚点,寻找锚点的逻辑是,从上往下,找到第一个非remove状态的Item。在本Case中,找到Item3

  2. 移除屏幕上的Views,将它们的ViewHolder放入到Recycler的mAttachedScrap缓存中,这个缓存的好处是如果position对应上了,无需重新绑定,直接拿来用。

  3. 从锚点Item3处往下填充,mAttachedScrap只剩下ViewHolder2和ViewHolder1

  4. 从锚点Item3处往上填充Item2 Item1,因为Item2,Imte1已经被remove掉了,它消耗的空间不会被记录,那么到步骤5的时候还可以填充

  5. 还有多余的空间,继续填充,把Item7、Item8填充到屏幕中

  6. 因为当前是预布局,直接返回

至此step1的layout结束

1.2 dispatchLayoutStep2阶段

  1. 寻找填充的锚点,寻找锚点的逻辑是,从上往下,找到第一个非remove状态的Item。在本Case中,找到Item3

  2. 移除屏幕上的Views,将它们的ViewHolder放入到Recycler的mAttachedScrap缓存中

  3. 从锚点Item3处往下填充,填充到Item6为止,就没有足够的距离了,mAttachedScrap只剩下ViewHolder8,ViewHolder7,ViewHolder2,ViewHolder1

  4. 往上填充,虽然此时还有两个View的高度,但是此时,上边没有数据了,此处不填充

  5. 此时还有两个View的高度,继续往下填充

注意此时已经布局完成但是屏幕上部与第一个有GAP,会修复

 if (getChildCount() > 0) {
            // because layout from end may be changed by scroll to position
            // we re-calculate it.
            // find which side we should check for gaps.
            if (mShouldReverseLayout ^ mStackFromEnd) {
                int fixOffset = fixLayoutEndGap(endOffset, recycler, state, true);
                startOffset += fixOffset;
                endOffset += fixOffset;
                fixOffset = fixLayoutStartGap(startOffset, recycler, state, false);
                startOffset += fixOffset;
                endOffset += fixOffset;
            } else {
                int fixOffset = fixLayoutStartGap(startOffset, recycler, state, true);
                startOffset += fixOffset;
                endOffset += fixOffset;
                fixOffset = fixLayoutEndGap(endOffset, recycler, state, false);
                startOffset += fixOffset;
                endOffset += fixOffset;
            }
        }

修复后效果如下

  1. 当前不是预布局,但是因为ViewHolder1和ViewHolder2都是被Remove掉的,所以跳过

2. notifyItemInserted

假设在Item1下面插入两条数据AddItem1,AddItem2

2.1 dispatchLayoutStep1阶段

  1. 寻找锚点,找到Item1
  2. 移除屏幕上的Views,放入到mAttachedScrap中
  3. 锚点处从上往下填充
  4. 锚点处从下往上填充,由上图可知,上面没有空间了,不填充
  5. 判断是否还有剩余的空间,如果有在末尾填充,下面没空间了,不填充
  6. 因为当前是预布局阶段,不填充

2.2 dispatchLayoutStep2阶段

  1. 寻找锚点,找到Item1
  2. 移除屏幕上的Views,放入到mAttachedScrap中
  3. 锚点处从上往下填充,此时将变化后的数据填充到屏幕上,addItem1和addItem2被填充到item1下面
  4. 锚点处从下往上填充,由图可知,没有空间不填充
  5. 判断是否还有剩余的空间,由图可知,没有空间不填充
  6. 当前是layoutStep2阶段,会将mAttachScrap的内容,填充到屏幕末尾,ViewHolder5和ViewHolder6对应的ItemView被填充

2.3 dispatchLayoutStep3阶段

开始动画,动画结束后,item5和item6会被回收掉,此时会被回收到mCachedViews缓存池中

本篇不涉及到动画具体如何执行,且听下回分解吧。

往期文章

面试官:简历上最好不要写Glide,不是问源码那么简单

常见的链表翻转,字节跳动加了个条件,面试者高呼「我太难了」| 图解算法

面试官,怎样实现 Router 框架?

java 版剑指offer集锦

面试官系列 - LeetCode链表知识点&题型总结

徐公自叙

得屌丝者得天下,米粉≠屌丝

Gson 和 Kotlin data class 的避坑指南


如果你觉得对你有所帮助的话,可以关注我的公众号 徐公码字(stormjun94),第一时间会在上面更新




本文分享自微信公众号 - 徐公码字(stormjun94)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!