本文正在参与「金石计划」

ViewPager2系列:

  1. 图解RecyclerView缓存复用机制
  2. 图解RecyclerView预拉取机制
  3. 图解ViewPager2离屏加载机制(上)
  4. 图解ViewPager2离屏加载机制(下)

在文章开端之前,有一个问题想要问你:

在一个由TabLayout + ViewPager2组合而成的滑动视图中,当咱们点击标签页跳转到某个指定页面时,你是否想过,ViewPager2是怎样知道其要滑动到的坐标方位并完成流通的滑动动画的呢?

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

如果你答复不了这个问题,那么当你遇到一些因滑动视图来回切换而产生的古怪现象时,你可能会感到无从下手。

为了协助你了解这种交互背后的行为逻辑,本文将结合源码剖析动图演示两种办法来讲解,让你对滑动视图流通动画的巧妙规划有更深入的了解。

按例,先奉上思想导图一张,便利复习:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱


在上一篇文章的结尾部分,咱们提到,当增加TabLayout这一种新的交互办法后,会发现ViewPager2离屏加载机制的行为逻辑又有所不同了。这儿先总结出两者的主要不同点,再来逐个地进行解说和剖析:

  • 默许在翻滚方向上离屏加载一页:当以点击标签页的办法跳转时,默许会在滑动方向上额定离屏加载多一个页面项
  • 间隔方针过远时会先预跳再长跳:当间隔方针方位超越3页时,会先预跳到targetPos-3的方位,再履行滑润翻滚的动画

默许在翻滚方向上离屏加载1页

经过上一篇文章的讲解,咱们现已知道,ViewPager2设置的OffscreenPageLimit默许值为-1,也即默许不开启离屏加载机制。在按次序顺次切换这种交互场景下,每次都只会有一个页面项被增加至当时的视图层次结构中。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

但是,在改用成了点击标签页跳转这种交互办法后,状况发生了改变。

至所以什么改变,让咱们从源码中找到答案。

相同,以LinearLayoutManager为例,让咱们再次回忆ViewPager2关于calculateExtraLayoutSpace办法的重写:

private class LinearLayoutManagerImpl extends LinearLayoutManager {
    /**
    * 核算额定的布局空间
    */
    @Override
    protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
            @NonNull int[] extraLayoutSpace) {
        int pageLimit = getOffscreenPageLimit();
        if (pageLimit == OFFSCREEN_PAGE_LIMIT_DEFAULT) {
            // 当OffscreenPageLimit为默许值时,会调用回父类也即LinearLayoutManager的calculateExtraLayoutSpace办法
            super.calculateExtraLayoutSpace(state, extraLayoutSpace);
            return;
        }
        ...
    }
}

能够看到,当OffscreenPageLimit为默许值时,会调用回父类也即LinearLayoutManager的calculateExtraLayoutSpace办法:

public class LinearLayoutManager extends RecyclerView.LayoutManager implements
        ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {
    /**
    * 核算额定的布局空间
    */
    protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
            @NonNull int[] extraLayoutSpace) {
        int extraLayoutSpaceStart = 0;
        int extraLayoutSpaceEnd = 0;
        // 获取LayoutManager应安置的额定空间量
        int extraScrollSpace = getExtraLayoutSpace(state);
        ...
    }
}

在此办法中,首要会调用getExtraLayoutSpace办法,获取LayoutManager应安置的额定空间量:

    /**
    * 获取应安置的额定空间量
    */
    protected int getExtraLayoutSpace(RecyclerView.State state) {
        if (state.hasTargetScrollPosition()) {
            // 当时有要翻滚到的方针方位,依据翻滚的方向获取应布局的总空间量
            return mOrientationHelper.getTotalSpace();
        } else {
            return 0;
        }
    }

此刻,差异就在getExtraLayoutSpace这个办法中表现了:

hasTargetScrollPosition这个办法回来true,表明当时有要翻滚到的方针方位,点击标签页跳转就属于这种状况。

毫无疑问,它进入了第一个条件语句,接下来便是调用getTotalSpace办法,依据翻滚的方向获取应布局的总空间量了。这儿咱们只考虑水平翻滚的状况,则应关注的是createHorizontalHelper办法的重载完成:

    public static OrientationHelper createHorizontalHelper(
            RecyclerView.LayoutManager layoutManager) {
        return new OrientationHelper(layoutManager) {
            /**
            * 依据翻滚的方向获取应布局的总空间量
            */
            @Override
            public int getTotalSpace() {
                return mLayoutManager.getWidth() - mLayoutManager.getPaddingLeft()
                        - mLayoutManager.getPaddingRight();
            }
        }
    }

这儿简略了解便是回来了正常一页的宽度。咱们能够重载此办法以完成咱们的自界说的加载策略,比如回来2页或3页的宽度。但是,安置不行见的元素通常会带来明显的功能本钱,这个在咱们上一篇文章里也有讲过。

接下来再次回到LinearLayoutManager的calculateExtraLayoutSpace办法:

    protected void calculateExtraLayoutSpace(@NonNull RecyclerView.State state,
            @NonNull int[] extraLayoutSpace) {
        ...
        // 依据布局的填充方向,决定将应安置的额定空间量赋值给哪一个变量
        if (mLayoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
            extraLayoutSpaceStart = extraScrollSpace;
        } else {
            extraLayoutSpaceEnd = extraScrollSpace;
        }
        extraLayoutSpace[0] = extraLayoutSpaceStart;
        extraLayoutSpace[1] = extraLayoutSpaceEnd;
    }

这儿会依据布局的填充方向,决定将应安置的额定空间量是赋值给extraLayoutSpaceStart还是extraLayoutSpaceEnd。二者只能有一个被赋值,别的一个保持为0.

这便是咱们所说的“默许会在滑动方向上额定离屏加载多一个页面项”。这么做有两个意图:

  1. 提早获悉翻滚方针坐标方位:额定安置的内容有助于LinearLayoutManager提早获悉其间隔要翻滚到的方针的坐标方位还有多远,以完成尽早地滑润地减速。
  2. 接连翻滚时动画愈加滑润流通:当翻滚的动作是接连的时,额定安置的内容有助于LinearLayoutManager完成愈加滑润而流通的动画。

该怎样了解呢?这就又回到了咱们开头提的那个问题了:

当咱们点击标签页跳转到某个指定页面时,ViewPager2是怎样知道其要滑动到的坐标方位并完成流通的滑动动画的呢?

答案,一言以蔽之:

车到山前必有路,柳暗花明又一村

用愈加通俗易懂的语言来解说便是:

先建立一个“小方针”,然后翻滚起来再说,等承认了要翻滚到的坐标方位之后,再减速停下来

是不是有点违背你的认知?听完我下面结合源码的剖析,你就懂了。

建立“小方针”

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

首要,当咱们以点击标签页这一动作为切入点开端源码剖析,你会发现一个这么长的调用链:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

这儿咱们只需求关注最中心的ViewFlinger#run办法,这个办法是滑动视图中几项重要作业的发起点,包括布局翻滚以及预拉取

在该办法内部,当SmoothScroller(滑润翻滚器)已发动但没有收到第一个动画回调时,它会自动触发一个翻滚间隔为0的回调

class ViewFlinger implements Runnable {
    @Override
    public void run() {
        ...
        // 已发动但没有收到第一个动画回调,自动触发一个回调
        SmoothScroller smoothScroller = mLayout.mSmoothScroller;
        if (smoothScroller != null && smoothScroller.isPendingInitialRun()) {
            smoothScroller.onAnimation(0, 0);
        }
        ...
}    

其回调的办法onAnimation会进入一个if块的履行,该if块会使 LinearLayoutManager以既定的方向翻滚 1 个像素的间隔,从而促进 LinearLayoutManager提早制作后两个页面项的视图,为什么会是两个页面项前面现已解说过了。

public abstract static class SmoothScroller {
    void onAnimation(int dx, int dy) {
        ...
        if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
            PointF pointF = computeScrollVectorForPosition(mTargetPosition);
            if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
                // 使 LinearLayoutManager以既定的方向翻滚 1 个像素的间隔,从而促进 LinearLayoutManager提早制作后两个页面项的视图
                recyclerView.scrollStep(
                        (int) Math.signum(pointF.x),
                        (int) Math.signum(pointF.y),
                        null);
            }
        }
        ...
    }
}

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

那又是基于什么原因,要提早制作后两个页面项的视图呢?这是为了在下一步的初始预估翻滚之前,测验提早找到要翻滚到的方针视图,从而承认要翻滚的实践间隔,避免初始翻滚的间隔超越视图自身。让咱们持续往下看:

SmoothScroller的每次翻滚都会回调onSeekTargetStep办法,直到在布局中找到方针视图的方位才中止回调:

public abstract static class SmoothScroller {
    void onAnimation(int dx, int dy) {
        ...
        if (mRunning) {
            // 每次翻滚都会回调`onSeekTargetStep`办法,直到在布局中找到方针视图的方位才中止回调
            onSeekTargetStep(dx, dy, recyclerView.mState, mRecyclingAction);
            ...
        }
        ...
    }
}

在此办法中,SmoothScroller会查看翻滚的间隔dx、dy,如果翻滚的间隔需求更改,则会供给一个新的RecyclerView.SmoothScroller.Action对象以界说下一次翻滚的行为

public class LinearSmoothScroller extends RecyclerView.SmoothScroller {
    /** 为了搜索方针视图而触发的翻滚间隔,单位为像素 */
    private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000;
    /** 为了搜索方针视图而触发的额定翻滚比率 */
    private static final float TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f;
    @Override
    protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
        ...
        mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx);
        mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy);
        // 查看翻滚的间隔dx、dy,看翻滚的间隔是否需求更改
        if (mInterimTargetDx == 0 && mInterimTargetDy == 0) {
            updateActionForInterimTarget(action);
        }
        ...
    }
    protected void updateActionForInterimTarget(Action action) {
        ...
        mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x);
        mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y);
        final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX);
        // 供给一个新的`RecyclerView.SmoothScroller.Action`对象以界说下一次翻滚的行为
        action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO),
                (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator);
    }
}

为了搜索要翻滚到的方针视图,SmoothScroller会触发一个比实践方针更远的翻滚间隔,以避免翻滚过程的UI卡顿

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

如果按源码里的算法,则在前面的初始阶段因那1个像素触发的预估翻滚间隔应是:

TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x * TARGET_SEEK_EXTRA_SCROLL_RATIO = 10000 * 1px * 1.2f = 12000px

算出来的这个数值有点夸张,在一个1920×1080分辨率的手机上,都足以让ViewPager2滑动超越11个页面项的间隔了。但莫要担心,接下来会咱们持续盯梢跋涉的间隔,而且当搜索到方针视图后,就会对这个翻滚的间隔进行修正。

翻滚起来

核算出预估的翻滚间隔后,咱们就会调用Action#runIfNecessary,进而调用ViewFlinger#smoothScrollBy办法来实践履行滑润翻滚的动画了,并在随后post一个Runnable再次履行ViewFlinger#run办法。

public abstract static class SmoothScroller {
    void onAnimation(int dx, int dy) {
        ...
        if (mRunning) {
            mRecyclingAction.runIfNecessary(recyclerView);;
            ...
        }
        ...
    }
}
public static class Action {
    void runIfNecessary(RecyclerView recyclerView) {
        if (mChanged) {
            recyclerView.mViewFlinger.smoothScrollBy(mDx, mDy, mDuration, mInterpolator);
            ...
            mChanged = false;
        }
    }
}
class ViewFlinger implements Runnable {
    public void smoothScrollBy(int dx, int dy, int duration,
                @Nullable Interpolator interpolator) {
        ...
        // 实践履行滑润翻滚的动画
        mOverScroller.startScroll(0, 0, dx, dy, duration);
        ...
        // post一个Runnable再次履行`ViewFlinger#run`办法
        postOnAnimation();
    }
} 

承认翻滚方位

这儿假定咱们想跳转到的是页面1,则因为在上一轮咱们现已提早制作了后两个页面项(即页面1,页面2)的视图,也即咱们现已搜索到了方针视图,因而在这一轮的onAnimation办法回调中咱们会进入这样一个if块:

public abstract static class SmoothScroller {
    void onAnimation(int dx, int dy) {
        ...
        // 搜索到方针视图
        if (mTargetView != null) {
            // 验证方针方位
            if (getChildPosition(mTargetView) == mTargetPosition) {
                onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
                ...
            } else {
                ...
            }
        }
        ...
    }

如果验证方针方位正确,则将履行onTargetFound回调,正是在这个回调里修正实践应翻滚的间隔。

public class LinearSmoothScroller extends RecyclerView.SmoothScroller {
        @Override
    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        ...
        if (time > 0) {
            // 修正实践应翻滚的间隔
            action.update(-dx, -dy, time, mDecelerateInterpolator);
        }
    }
}

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

减速中止

随后,会用修正后的间隔,持续履行滑润翻滚的动画。并在最终重置mTargetPosition、清除对LayoutManager和RecyclerView引用以避免潜在的内存泄漏、通知各个注册的动画回调SmoothScroller翻滚已中止。

public abstract static class SmoothScroller {
    void onAnimation(int dx, int dy) {
        ...
        if (mTargetView != null) {
            if (getChildPosition(mTargetView) == mTargetPosition) {
                ...
                // 履行滑润翻滚的动画
                mRecyclingAction.runIfNecessary(recyclerView);
                // 中止
                stop();
            } else {
                ...
            }
        }
        ...
    }
}
protected final void stop() {
    if (!mRunning) {
        return;
    }
    mRunning = false;
    onStop();
    mRecyclerView.mState.mTargetPosition = RecyclerView.NO_POSITION;
    mTargetView = null;
    // 重置mTargetPosition
    mTargetPosition = RecyclerView.NO_POSITION;
    mPendingInitialRun = false;
    // 通知各个注册的动画回调SmoothScroller翻滚已中止
    mLayoutManager.onSmoothScrollerStopped(this);
    // 清除引用以避免潜在的内存泄漏 smooth scroller
    mLayoutManager = null;
    mRecyclerView = null;
}

下面让咱们经过动图演示来完好还原这整个流程:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 当滑动视图初始化完成时,只要页面0会被增加至当时视图层次结构中。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 跟着咱们点击标签页,在翻滚开端的初始阶段,会先在翻滚方向上移动1个像素的间隔,这会促进页面1被提早加载出来,一起额定离屏加载多一个页面2。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 因为暂时不承认方针视图的具体方位,因而,滑动视图会先触发一个比实践方针更远的预估翻滚间隔,随后开端履行滑润翻滚的动画。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 接下来,因为咱们现已提早加载了页面1,方针视图的具体方位已能够承认,因而咱们会修正实践应翻滚的间隔,随后持续履行滑润翻滚的动画,最终减速中止。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 在此过程中,预拉取的作业也会正常进行,按咱们在系列第二篇剖析的预拉取流程,此刻预拉取的应是页面3。

  2. 一起,页面0也将跟随向左的滑润翻翻滚画被移出屏幕,并放入mCachedView中。

——先不忙着结束,假定一开端咱们想跳转到的是页面3,则状况又会有什么不同呢?首要,前三个过程几乎完全相同,主要差异就出现在过程4:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

接下来,因为方针视图(即页面3)仍未被加载出来,因而翻滚不会中止,mTargetPosition不会被重置,hasTargetScrollPosition办法仍回来true,因而,页面0和页面1会跟着滑动持续进行被收回,页面3也会跟着滑动持续进行被离屏加载出来。

之后,才又联接上了上面的过程4,承认了方针视图的方位,修正实践应翻滚的间隔,随后履行滑润翻滚,最终减速中止,并预加载页面4及收回页面2。

间隔方针过远时会先预跳再长跳

透过以上流程,你可能会发现,虽然缓存复用机制、预拉取机制、离屏加载机制都在此流程中各司其职,但其中的大部分作业都只能算是履行滑润翻翻滚画过程中的副产物,咱们真实想要加载并展示的其实只是页面3。

这种状况在总页面数比较少时还问题不大,一旦总页数多了起来,问题也随之露出:一方面,很多不必要的作业会额定消耗资源,另一方面,动画的展示作用也将不符合预期。

考虑一下,假定动画平均时长不变,跟着页面变多,总动画时长也将变长,动画过久的话体验肯定欠好;而假定动画总时长不变,跟着页面变多,动画平均时长将变短,动画过快的话体验也欠好。

所以,为了避免这种状况,ViewPager2规划了一种预跳机制,也即为了保证滑动动画的全体作用,会先预跳到邻近的项目再进行长跳:

public final class ViewPager2 extends ViewGroup {
    void setCurrentItemInternal(int item, boolean smoothScroll) {
        ...
        // 为了滑润翻滚,会先预跳到邻近的项目再进行长跳
        if (Math.abs(item - previousItem) > 3) {
            mRecyclerView.scrollToPosition(item > previousItem ? item - 3 : item + 3);
            mRecyclerView.post(new SmoothScrollToPosition(item, mRecyclerView));
        } else {
            mRecyclerView.smoothScrollToPosition(item);
        }
    }
}

其他的流程则与上一节的差异不大,但为了清晰还原预跳机制及之后的整个流程,咱们相同会以动图办法来演示。

假定咱们要跳转到的是页面5:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 当滑动视图初始化完成时,只要页面0会被增加至当时视图层次结构中。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 因为页面5间隔页面1超越3页,因而会先预跳到页面2的方位,页面2因而被增加至当时视图层次结构中。随后,页面0被收回,滑动视图准备履行滑润翻滚的动画。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 在翻滚开端的初始阶段,会先在翻滚方向上移动1个像素的间隔,这会促进页面3被提早加载出来,一起额定离屏加载多一个页面4。

  2. 因为暂时不承认方针视图的具体方位,滑动视图会先触发一个比实践方针更远的预估翻滚间隔,随后开端履行滑润翻滚的动画。

  3. 在此过程中,预拉取的作业也会正常进行,此刻预拉取的应是页面5。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 接下来,因为发现页面5仍未被加载出来,因而翻滚不会中止,跟着滑动的持续进行,页面2会被收回,页面5也会取之前预拉取好的内容并被离屏加载出来。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 然后,跟着页面4完好出现在屏幕中,页面3也会被收回,但因为超越了mCachedView巨细的约束,页面3测验进入时,会先依照先进先出的次序,先从mCachedView中移出页面0,放入RecyclerPool中对应itemType的ArrayList容器中。

  2. 在此过程中,预拉取的作业也会正常进行,此刻预拉取的应是页面6。

  3. 而跟着页面5被离屏加载出来,方针视图的具体方位已能够承认,因而咱们会修正实践应翻滚的间隔,随后持续履行滑润翻滚的动画,最终减速中止。

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

  1. 一起,页面4也将跟随向左的滑润滑动动画被移出屏幕,而且,相同因为超越了mCachedView巨细的约束,会先移除页面2再放入页面4。

封闭了滑润翻翻滚画的状况

在实践的项目开发中,有时候并不需求开启滑润翻滚的动画作用,这种状况常出现在首页的多页面视图中。

要封闭滑润翻滚的动画作用,只需求运用TabLayoutMediator的另一个带smoothScroll参数的构造函数并传入false即可:

public TabLayoutMediator(
      @NonNull TabLayout tabLayout,
      @NonNull ViewPager2 viewPager,
      boolean autoRefresh,
      boolean smoothScroll,
      @NonNull TabConfigurationStrategy tabConfigurationStrategy) {
      ...
}      

而既然封闭了滑润翻滚的动画作用,以上提到的那些问题也将不复存在,流程将得到极大的简化,3大机制中只要缓存复用机制会持续作业,如图:

【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

总结

好了,以上便是ViewPager2离屏加载机制的全部内容了,按例,咱们结合上篇内容来最终总结一下:

离屏加载机制
意图 削减切换分页时花费在视图创立与布局上的时刻,从而提高ViewPager2滑动时的全体流通度
办法 扩展额定的布局空间,以提早创立并保存屏幕两边的页面来完成的
要害参数 mOffscreenPageLimit,默许值为-1,也即默许不开启离屏加载机制。
功能影响 白屏时刻、流通度、内存占用等
搭配TabLayout 1. 默许在滑动方向上离屏加载多一页;2. 间隔方针过远时会先预跳再长跳
主张点 1. 依据应用当时的内存运用状况,对mOffscreenPageLimit值进行动态调整,在行为表现与功能影响上取一个平衡点。2. 需求维护好Fragment重建以及视图收回/复用时的处理逻辑。