【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱 |
您所在的位置:网站首页 › 忘记密码后怎么办vivo › 【动画图解】TabLayout + ViewPager2 : 揭开滑动视图流畅动画的神秘面纱 |
本文正在参加「金石计划」 在文章开始之前,有一个问题想要问你: 在一个由TabLayout + ViewPager2组合而成的滑动视图中,当我们点击标签页跳转到某个指定页面时,你是否想过,ViewPager2是怎么知道其要滑动到的坐标位置并实现流畅的滑动动画的呢? 如果你回答不了这个问题,那么当你遇到一些因滑动视图来回切换而产生的奇怪现象时,你可能会感到无从下手。 为了帮助你理解这种交互背后的行为逻辑,本文将结合源码分析与动图演示两种形式来讲解,让你对滑动视图流畅动画的巧妙设计有更深入的了解。 照例,先奉上思维导图一张,方便复习: 在上一篇文章的结尾部分,我们提到,当增加TabLayout这一种新的交互方式后,会发现ViewPager2离屏加载机制的行为逻辑又有所不同了。这里先总结出两者的主要不同点,再来逐一地进行解释和分析: 默认在滚动方向上离屏加载一页:当以点击标签页的方式跳转时,默认会在滑动方向上额外离屏加载多一个页面项 距离目标过远时会先预跳再长跳:当距离目标位置超过3页时,会先预跳到targetPos-3的位置,再执行平滑滚动的动画 默认在滚动方向上离屏加载1页经过上一篇文章的讲解,我们已经知道,ViewPager2设置的OffscreenPageLimit默认值为-1,也即默认不开启离屏加载机制。在按顺序依次切换这种交互场景下,每次都只会有一个页面项被添加至当前的视图层次结构中。 但是,在改用成了点击标签页跳转这种交互方式后,情况发生了变化。 至于是什么变化,让我们从源码中找到答案。 同样,以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是怎么知道其要滑动到的坐标位置并实现流畅的滑动动画的呢? 答案,一言以蔽之: 车到山前必有路,柳暗花明又一村 用更加通俗易懂的语言来解释就是: 先设立一个“小目标”,然后滚动起来再说,等确定了要滚动到的坐标位置之后,再减速停下来。 是不是有点违反你的认知?听完我下面结合源码的分析,你就懂了。 设立“小目标”首先,当我们以点击标签页这一动作为切入点开始源码分析,你会发现一个这么长的调用链: 这里我们只需要关注最核心的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); } } ... } } 复制代码那又是基于什么原因,要提前绘制后两个页面项的视图呢?这是为了在下一步的初始预估滚动之前,尝试提前找到要滚动到的目标视图,从而确认要滚动的实际距离,防止初始滚动的距离超过视图本身。让我们继续往下看: 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卡顿。 如果按源码里的算法,则在前面的初始阶段因那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); } } } 复制代码随后,会用修正后的距离,继续执行平滑滚动的动画。并在最后重置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; } 复制代码下面让我们通过动图演示来完整还原这整个流程: 在此过程中,预拉取的工作也会正常进行,按我们在系列第二篇分析的预拉取流程,此时预拉取的应是页面3。 同时,页面0也将跟随向左的平滑滚动动画被移出屏幕,并放入mCachedView中。 ——先不忙着结束,假设一开始我们想跳转到的是页面3,则情况又会有什么不同呢?首先,前三个步骤几乎完全相同,主要区别就出现在步骤4: 接下来,由于目标视图(即页面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: 在滚动开始的初始阶段,会先在滚动方向上移动1个像素的距离,这会促使页面3被提前加载出来,同时额外离屏加载多一个页面4。 由于暂时不确定目标视图的具体位置,滑动视图会先触发一个比实际目标更远的预估滚动距离,随后开始执行平滑滚动的动画。 在此过程中,预拉取的工作也会正常进行,此时预拉取的应是页面5。 然后,随着页面4完整出现在屏幕中,页面3也会被回收,但由于超过了mCachedView大小的限制,页面3尝试进入时,会先按照先进先出的顺序,先从mCachedView中移出页面0,放入RecyclerPool中对应itemType的ArrayList容器中。 在此过程中,预拉取的工作也会正常进行,此时预拉取的应是页面6。 而随着页面5被离屏加载出来,目标视图的具体位置已可以确定,因此我们会修正实际应滚动的距离,随后继续执行平滑滚动的动画,最后减速停止。 在实际的项目开发中,有时候并不需要开启平滑滚动的动画效果,这种情况常出现在首页的多页面视图中。 要关闭平滑滚动的动画效果,只需要使用TabLayoutMediator的另一个带smoothScroll参数的构造函数并传入false即可: public TabLayoutMediator( @NonNull TabLayout tabLayout, @NonNull ViewPager2 viewPager, boolean autoRefresh, boolean smoothScroll, @NonNull TabConfigurationStrategy tabConfigurationStrategy) { ... } 复制代码而既然关闭了平滑滚动的动画效果,以上提到的那些问题也将不复存在,流程将得到极大的简化,3大机制中只有缓存复用机制会继续工作,如图: 好了,以上就是ViewPager2离屏加载机制的全部内容了,照例,我们结合上篇内容来最后总结一下: 离屏加载机制目的减少切换分页时花费在视图创建与布局上的时间,从而提升ViewPager2滑动时的整体流畅度方式扩展额外的布局空间,以提前创建并保留屏幕两侧的页面来实现的关键参数mOffscreenPageLimit,默认值为-1,也即默认不开启离屏加载机制。性能影响白屏时间、流畅度、内存占用等搭配TabLayout1. 默认在滑动方向上离屏加载多一页;2. 距离目标过远时会先预跳再长跳建议点1. 根据应用当前的内存使用情况,对mOffscreenPageLimit值进行动态调整,在行为表现与性能影响上取一个平衡点。2. 需要维护好Fragment重建以及视图回收/复用时的处理逻辑。 |
今日新闻 |
点击排行 |
|
推荐新闻 |
图片新闻 |
|
专题文章 |
CopyRight 2018-2019 实验室设备网 版权所有 win10的实时保护怎么永久关闭 |