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

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

如何阅读本篇文章



本文主要讲解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();
      }
  }
}


2.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。


image.png

  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



image.png

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


image.png

image.png

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


image.png

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


image.png

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

至此step1的layout结束


1.2 dispatchLayoutStep2阶段


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

image.png

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

image.png

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

image.png

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

image.png

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


image.png


注意此时已经布局完成但是屏幕上部与第一个有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;
            }
        }


修复后效果如下


image.png

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

image.png

2. notifyItemInserted


image.png


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


2.1 dispatchLayoutStep1阶段


  1. 寻找锚点,找到Item1

image.png



2. 移除屏幕上的Views,放入到mAttachedScrap中

image.png


3. 锚点处从上往下填充


image.png


4. 锚点处从下往上填充,由上图可知,上面没有空间了,不填充 5. 判断是否还有剩余的空间,如果有在末尾填充,下面没空间了,不填充 6. 因为当前是预布局阶段,不填充


2.2 dispatchLayoutStep2阶段


  1. 寻找锚点,找到Item1


image.png

2. 移除屏幕上的Views,放入到mAttachedScrap中

image.png

3. 锚点处从上往下填充,此时将变化后的数据填充到屏幕上,addItem1和addItem2被填充到item1下面

image.png

4. 锚点处从下往上填充,由图可知,没有空间不填充 5. 判断是否还有剩余的空间,由图可知,没有空间不填充 6. 当前是layoutStep2阶段,会将mAttachScrap的内容,填充到屏幕末尾,ViewHolder5和ViewHolder6对应的ItemView被填充


image.png

2.3 dispatchLayoutStep3阶段


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


image.png

相关文章
|
6月前
|
前端开发 JavaScript API
如何实现两栏布局?这篇文章告诉你所有的细节!
欢迎来到前端入门之旅!这个专栏是为那些对Web开发感兴趣、刚刚开始学习前端的读者们打造的。无论你是初学者还是有一些基础的开发者,我们都会在这里为你提供一个系统而又亲切的学习平台。我们以问答形式更新,为大家呈现精选的前端知识点和最佳实践。通过深入浅出的解释概念,并提供实际案例和练习,让你逐步建立起一个扎实的基础。无论是HTML、CSS、JavaScript还是最新的前端框架和工具,我们都将为你提供丰富的内容和实用技巧,帮助你更好地理解并运用前端开发中的各种技术。
|
6月前
|
XML Java Android开发
Android App开发中集合动画和属性动画的讲解及实战演示(附源码 简单易懂 可直接使用)
Android App开发中集合动画和属性动画的讲解及实战演示(附源码 简单易懂 可直接使用)
59 0
Android App开发中集合动画和属性动画的讲解及实战演示(附源码 简单易懂 可直接使用)
|
6月前
|
Android开发 Kotlin
android开发,使用kotlin学习滚动控件RecyclerView
android开发,使用kotlin学习滚动控件RecyclerView
170 0
自己动手写RecyclerView的上拉加载
自己动手写RecyclerView的上拉加载
自己动手写RecyclerView的下拉刷新1
自己动手写RecyclerView的下拉刷新
自己动手写RecyclerView的下拉刷新2
自己动手写RecyclerView的下拉刷新
|
消息中间件 存储 缓存
RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势
RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势
129 0
|
存储 缓存
RecyclerView 动画原理 | 换个姿势看源码(pre-layout)
RecyclerView 动画原理 | 换个姿势看源码(pre-layout)
79 0
|
存储 消息中间件 缓存
RecyclerView 的滚动时怎么实现的?(二)| Fling
RecyclerView 的滚动时怎么实现的?(二)| Fling
196 0
|
XML Android开发 uml
Android 补间动画及动画组合AnimationSet常用方法整理
`Android`常用的四种补间动画分别为`RotateAnimation`、`ScaleAnimation`、`TranslateAnimation`、`AlphaAnimation`,他们的父类为`Animation`
149 0