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

您所在的位置:网站首页 忘记密码后怎么办vivo 【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱

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

2024-07-16 03:23:29| 来源: 网络整理| 查看: 265

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

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

在一个由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.

这就是我们所说的“默认会在滑动方向上额外离屏加载多一个页面项”。这么做有两个目的:

提前获知滚动目标坐标位置:额外布置的内容有助于LinearLayoutManager提前获知其距离要滚动到的目标的坐标位置还有多远,以实现尽早地平滑地减速。 连续滚动时动画更加平滑流畅:当滚动的动作是连续的时,额外布置的内容有助于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

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

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

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

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

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

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

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

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

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

同时,页面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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

同时,页面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重建以及视图回收/复用时的处理逻辑。


【本文地址】

公司简介

联系我们

今日新闻


点击排行

实验室常用的仪器、试剂和
说到实验室常用到的东西,主要就分为仪器、试剂和耗
不用再找了,全球10大实验
01、赛默飞世尔科技(热电)Thermo Fisher Scientif
三代水柜的量产巅峰T-72坦
作者:寞寒最近,西边闹腾挺大,本来小寞以为忙完这
通风柜跟实验室通风系统有
说到通风柜跟实验室通风,不少人都纠结二者到底是不
集消毒杀菌、烘干收纳为一
厨房是家里细菌较多的地方,潮湿的环境、没有完全密
实验室设备之全钢实验台如
全钢实验台是实验室家具中较为重要的家具之一,很多

推荐新闻


图片新闻

实验室药品柜的特性有哪些
实验室药品柜是实验室家具的重要组成部分之一,主要
小学科学实验中有哪些教学
计算机 计算器 一般 打孔器 打气筒 仪器车 显微镜
实验室各种仪器原理动图讲
1.紫外分光光谱UV分析原理:吸收紫外光能量,引起分
高中化学常见仪器及实验装
1、可加热仪器:2、计量仪器:(1)仪器A的名称:量
微生物操作主要设备和器具
今天盘点一下微生物操作主要设备和器具,别嫌我啰嗦
浅谈通风柜使用基本常识
 众所周知,通风柜功能中最主要的就是排气功能。在

专题文章

    CopyRight 2018-2019 实验室设备网 版权所有 win10的实时保护怎么永久关闭