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

5,169 阅读16分钟

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

ViewPager2系列:

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

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

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

01点击标签页跳转到某个指定页面.small.gif

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

为了帮助你理解这种交互背后的行为逻辑,本文将结合源码分析动图演示两种形式来讲解,让你对滑动视图流畅动画的巧妙设计有更深入的了解。

照例,先奉上思维导图一张,方便复习:

02TabLayout + ViewPager2 _ 揭开滑动视图流畅动画的神秘面纱.png


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

  • 默认在滚动方向上离屏加载一页:当以点击标签页的方式跳转时,默认会在滑动方向上额外离屏加载多一个页面项
  • 距离目标过远时会先预跳再长跳:当距离目标位置超过3页时,会先预跳到targetPos-3的位置,再执行平滑滚动的动画

默认在滚动方向上离屏加载1页

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

03每次都只会有一个页面项被添加至当前视图层次结构中.small.gif

但是,在改用成了点击标签页跳转这种交互方式后,情况发生了变化。

至于是什么变化,让我们从源码中找到答案。

同样,以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是怎么知道其要滑动到的坐标位置并实现流畅的滑动动画的呢?

答案,一言以蔽之:

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

用更加通俗易懂的语言来解释就是:

先设立一个“小目标”,然后滚动起来再说,等确定了要滚动到的坐标位置之后,再减速停下来

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

设立“小目标”

03.5 小目标.webp

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

04点击标签页事件方法调用链.png

这里我们只需要关注最核心的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);
            }
        }
        ...
    }
}

05提前绘制后两个页面项的视图.small.gif

那又是基于什么原因,要提前绘制后两个页面项的视图呢?这是为了在下一步的初始预估滚动之前,尝试提前找到要滚动到的目标视图,从而确认要滚动的实际距离,防止初始滚动的距离超过视图本身。让我们继续往下看:

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卡顿

06触发一个比实际目标更远的滚动距离.small.gif

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

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

算出来的这个数值有点夸张,在一个1920x1080分辨率的手机上,都足以让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);
        }
    }
    
}

07修正实际应滚动的距离.small.gif

减速停止

随后,会用修正后的距离,继续执行平滑滚动的动画。并在最后重置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;
}

下面让我们通过动图演示来完整还原这整个流程:

08只有页面0会被添加至当前视图层次结构中.small.gif

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

05提前绘制后两个页面项的视图.small.gif

  1. 随着我们点击标签页,在滚动开始的初始阶段,会先在滚动方向上移动1个像素的距离,这会促使页面1被提前加载出来,同时额外离屏加载多一个页面2。

06触发一个比实际目标更远的滚动距离.small.gif

  1. 由于暂时不确定目标视图的具体位置,因此,滑动视图会先触发一个比实际目标更远的预估滚动距离,随后开始执行平滑滚动的动画。

07修正实际应滚动的距离.small.gif

  1. 接下来,由于我们已经提前加载了页面1,目标视图的具体位置已可以确定,因而我们会修正实际应滚动的距离,随后继续执行平滑滚动的动画,最后减速停止。

09预拉取页面3缓存页面0.small.gif

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

  2. 同时,页面0也将跟随向左的平滑滚动动画被移出屏幕,并放入mCachedView中。

——先不忙着结束,假设一开始我们想跳转到的是页面3,则情况又会有什么不同呢?首先,前三个步骤几乎完全相同,主要区别就出现在步骤4:

10页面0和页面1会随着滑动继续进行被回收.small.gif

接下来,由于目标视图(即页面3)仍未被加载出来,因此滚动不会停止,mTargetPosition不会被重置,hasTargetScrollPosition方法仍返回true,因此,页面0和页面1会随着滑动继续进行被回收,页面3也会随着滑动继续进行被离屏加载出来。

11预加载页面4及回收页面2.small.gif

之后,才又衔接上了上面的步骤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:

08只有页面0会被添加至当前视图层次结构中.small.gif

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

12先预跳到页面2的位置.small.gif

  1. 由于页面5距离页面1超过3页,因此会先预跳到页面2的位置,页面2因此被添加至当前视图层次结构中。随后,页面0被回收,滑动视图准备执行平滑滚动的动画。

13促使页面3被提前加载出来.small.gif

  1. 在滚动开始的初始阶段,会先在滚动方向上移动1个像素的距离,这会促使页面3被提前加载出来,同时额外离屏加载多一个页面4。

  2. 由于暂时不确定目标视图的具体位置,滑动视图会先触发一个比实际目标更远的预估滚动距离,随后开始执行平滑滚动的动画。

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

14页面5取之前预拉取好的内容.small.gif

  1. 接下来,由于发现页面5仍未被加载出来,因此滚动不会停止,随着滑动的继续进行,页面2会被回收,页面5也会取之前预拉取好的内容并被离屏加载出来。

15页面4完整出现在屏幕中.small.gif

  1. 然后,随着页面4完整出现在屏幕中,页面3也会被回收,但由于超过了mCachedView大小的限制,页面3尝试进入时,会先按照先进先出的顺序,先从mCachedView中移出页面0,放入RecyclerPool中对应itemType的ArrayList容器中。

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

  3. 而随着页面5被离屏加载出来,目标视图的具体位置已可以确定,因此我们会修正实际应滚动的距离,随后继续执行平滑滚动的动画,最后减速停止。

16移除页面2再放入页面4.small.gif

  1. 同时,页面4也将跟随向左的平滑滑动动画被移出屏幕,并且,同样由于超过了mCachedView大小的限制,会先移除页面2再放入页面4。

关闭了平滑滚动动画的情况

在实际的项目开发中,有时候并不需要开启平滑滚动的动画效果,这种情况常出现在首页的多页面视图中。

要关闭平滑滚动的动画效果,只需要使用TabLayoutMediator的另一个带smoothScroll参数的构造函数并传入false即可:

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

而既然关闭了平滑滚动的动画效果,以上提到的那些问题也将不复存在,流程将得到极大的简化,3大机制中只有缓存复用机制会继续工作,如图:

17只有缓存复用机制会继续工作.small.gif

总结

好了,以上就是ViewPager2离屏加载机制的全部内容了,照例,我们结合上篇内容来最后总结一下:

离屏加载机制
目的减少切换分页时花费在视图创建与布局上的时间,从而提升ViewPager2滑动时的整体流畅度
方式扩展额外的布局空间,以提前创建并保留屏幕两侧的页面来实现的
关键参数mOffscreenPageLimit,默认值为-1,也即默认不开启离屏加载机制。
性能影响白屏时间、流畅度、内存占用等
搭配TabLayout1. 默认在滑动方向上离屏加载多一页;2. 距离目标过远时会先预跳再长跳
建议点1. 根据应用当前的内存使用情况,对mOffscreenPageLimit值进行动态调整,在行为表现与性能影响上取一个平衡点。2. 需要维护好Fragment重建以及视图回收/复用时的处理逻辑。

少侠,请留步!若本文对你有所帮助或启发,还请:

  1. 点赞👍🏻,让更多的人能看到!
  2. 收藏⭐️,好文值得反复品味!
  3. 关注➕,不错过每一次更文!

===> 技术号:「星际码仔」💪

你的支持是我继续创作的动力,感谢!🙏