查看原文
其他

让你明明白白的使用 RecyclerView——SnapHelper 详解

2017-08-21 辰之猫 承香墨影

作者简介

本文由 辰之猫 原创并授权发布,未经原作者允许请勿转载。

之前推送过一篇 RecyclerView 的 DiffUtils ,反响还不错。本篇介绍另外一个 RecyclerView 的 辅助类 SnapHelper,用于处理滚动的时候的对齐规则,写的很细致,希望你喜欢。

辰之猫 的博客地址:

http://www.jianshu.com/p/e54db232df62


简介

RecyclerView 在 24.2.0 版本中新增了 SnapHelper 这个辅助类,用于辅助RecyclerView 在滚动结束时将 Item 对齐到某个位置。特别是列表横向滑动时,很多时候不会让列表滑到任意位置,而是会有一定的规则限制,这时候就可以通过 SnapHelper 来定义对齐规则了。

SnapHelper 是一个抽象类,官方提供了一个 LinearSnapHelper 的子类,可以让 RecyclerView 滚动停止时相应的 Item 停留中间位置。25.1.0 版本中官方又提供了一个 PagerSnapHelper 的子类,可以使 RecyclerView 像 ViewPager 一样的效果,一次只能滑一页,而且居中显示。

这两个子类使用方式也很简单,只需要创建对象之后调用 attachToRecyclerView() 附着到对应的 RecyclerView 对象上就可以了。

原理剖析

Fling操作

首先来了解一个概念,手指在屏幕上滑动 RecyclerView 然后松手, RecyclerView 中的内容会顺着惯性继续往手指滑动的方向继续滚动直到停止,这个过程叫做 Fling 。 Fling 操作从手指离开屏幕瞬间被触发,在滚动停止时结束。

三个抽象方法

SnapHelper 是一个抽象类,它有三个抽象方法:

public abstract int findTargetSnapPosition(    LayoutManager layoutManager,    int velocityX,    int velocityY)

该方法会根据触发 Fling 操作的速率(参数 velocityX 和参数 velocityY )来找到 RecyclerView 需要滚动到哪个位置,该位置对应的 ItemView 就是那个需要进行对齐的列表项。我们把这个位置称为 targetSnapPosition ,对应的 View 称为 targetSnapView 。如果找不到 targetSnapPosition ,就返回RecyclerView.NO_POSITION 。

public abstract View findSnapView(    LayoutManager layoutManager    )

该方法会找到当前 layoutManager 上最接近对齐位置的那个 view ,该 view 称为 SanpView ,对应的 position 称为 SnapPosition 。如果返回 null ,就表示没有需要对齐的 View ,也就不会做滚动对齐调整。

public abstract int[] calculateDistanceToFinalSnap(    @NonNull LayoutManager layoutManager,    @NonNull View targetView    );

这个方法会计算第二个参数对应的 ItemView 当前的坐标与需要对齐的坐标之间的距离。该方法返回一个大小为 2 的 int 数组,分别对应 x轴 和 y轴 方向上的距离。

attachToRecyclerView()

现在来看attachToRecyclerView()这个方法,SnapHelper 正是通过该方法附着到 RecyclerView 上,从而实现辅助 RecyclerView 滚动对齐操作。源码如下:

可以看到,在 attachToRecyclerView() 方法中会清掉 SnapHelper 之前保存的 RecyclerView 对象的回调(如果有的话),对新设置进来的 RecyclerView 对象设置回调,然后初始化一个 Scroller 对象,最后调用 snapToTargetExistingView() 方法对 SnapView 进行对齐调整。

snapToTargetExistingView()

该方法的作用是对 SnapView 进行滚动调整,以使得 SnapView 达到对齐效果。源码如下:

可以看到,snapToTargetExistingView()方法就是先找到SnapView,然后计算SnapView当前坐标到目的坐标之间的距离,然后调用RecyclerView.smoothScrollBy()方法实现对 RecyclerView 内容的平滑滚动,从而将 SnapView 移到目标位置,达到对齐效果。RecyclerView.smoothScrollBy() 这个方法的实现原理这里就不展开了 ,它的作用就是根据参数平滑滚动 RecyclerView 的中的 ItemView 相应的距离。

setupCallbacks()和destroyCallbacks()

再看下 SnapHelper 对 RecyclerView 设置了哪些回调:

可以看出 RecyclerView 设置的回调有两个:一个是 OnScrollListener 对象mScrollListener 。还有一个是 OnFlingListener 对象。由于 SnapHelper 实现了 OnFlingListener 接口,所以这个对象就是 SnapHelper 自身了。

先看下 mScrollListener 这个变量在怎样实现的。

该滚动监听器的实现很简单,只是在正常滚动停止的时候调用了 snapToTargetExistingView() 方法对 targetView 进行滚动调整,以确保停止的位置是在对应的坐标上,这就是 RecyclerView 添加该 OnScrollListener 的目的。

除了 OnScrollListener 这个监听器,还对 RecyclerView 还设置了 OnFlingListener 这个监听器,而这个监听器就是 SnapHelper 自身。因为 SnapHelper 实现了 RecyclerView.OnFlingListener 接口。我们先来看看 RecyclerView.OnFlingListener 这个接口。

这个接口中就只有一个onFling()方法,该方法会在 RecyclerView 开始做 fling 操作时被调用。我们来看看 SnapHelper 怎么实现 onFling() 方法:

注释解释得很清楚。看下 snapFromFling()怎么操作的:

可以看到,snapFromFling()方法会先判断 layoutManager 是否实现了 ScrollVectorProvider 接口,如果没有实现该接口就不允许通过该方法做滚动操作。那为啥一定要实现该接口呢?待会再来解释。接下来就去创建平滑滚动器 SmoothScroller 的一个实例, layoutManager 可以通过该平滑滚动器来进行滚动操作。 SmoothScroller 需要设置一个滚动的目标位置,我们将通过findTargetSnapPosition()方法来计算得到的 targetSnapPosition 给它,告诉滚动器要滚到这个位置,然后就启动 SmoothScroller 进行滚动操作。

但是这里有一点需要注意一下,默认情况下通过setTargetPosition()方法设置的 SmoothScroller 只能将对应位置的 ItemView 滚动到与 RecyclerView 的边界对齐,那怎么实现将该ItemView滚动到我们需要对齐的目标位置呢?就得对 SmoothScroller 进行一下处理了。

看下平滑滚动器 RecyclerView.SmoothScroller ,这个东西是通过createSnapScroller()方法创建得到的:

通过以上的分析可以看到,createSnapScroller()创建的是一个 LinearSmoothScroller ,并且在创建该 LinearSmoothScroller 的时候主要考虑两个方面:

  • 第一个是滚动速率,由calculateSpeedPerPixel()方法决定;

  • 第二个是在滚动过程中, targetView 即将要进入到视野时,将匀速滚动变换为减速滚动,然后一直滚动目的坐标位置,使滚动效果更真实,这是由onTargetFound()方法决定。


刚刚不是留了一个疑问么?就是正常模式下 SmoothScroller 通过 setTargetPosition()方法设置的 ItemView 只能滚动到与 RecyclerView 边缘对齐,而解决这个局限的处理方式就是在 SmoothScroller 的onTargetFound()方法中了。onTargetFound()方法会在 SmoothScroller 滚动过程中, targetSnapView 被 layout 出来时调用。而这个时候利用calculateDistanceToFinalSnap()方法得到 targetSnapView 当前坐标与目的坐标之间的距离,然后通过Action.update()方法改变当前 SmoothScroller 的状态,让 SmoothScroller 根据新的滚动距离、新的滚动时间、新的滚动差值器来滚动,这样既能将 targetSnapView 滚动到目的坐标位置,又能实现减速滚动,使得滚动效果更真实。

从图中可以看到,很多时候 targetSnapView 被 layout 的时候(onTargetFound()方法被调用)并不是紧挨着界面上的 Item ,而是会有一定的提前,这是由于 RecyclerView 为了优化性能,提高流畅度,在滑动滚动的时候会有一个预加载的过程,提前将 Item 给 layout 出来了,这个知识点涉及到的内容很多,这里做个理解就可以了,不详细细展开了,以后有时间会专门讲下 RecyclerView 的相关原理机制。

到了这里,整理一下前面的思路: SnapHelper 实现了 OnFlingListener 这个接口,该接口中的onFling()方法会在 RecyclerView 触发 Fling 操作时调用。在onFling()方法中判断当前方向上的速率是否足够做滚动操作,如果速率足够大就调用snapFromFling()方法实现滚动相关的逻辑。在snapFromFling()方法中会创建一个 SmoothScroller ,并且根据速率计算出滚动停止时的位置,将该位置设置给 SmoothScroller 并启动滚动。而滚动的操作都是由 SmoothScroller 全权负责,它可以控制 Item 的滚动速度(刚开始是匀速),并且在滚动到 targetSnapView 被 layout 时变换滚动速度(转换成减速),以让滚动效果更加真实。

所以,SnapHelper 辅助 RecyclerView 实现滚动对齐就是通过给 RecyclerView 设置 OnScrollerListener 和 OnFlingListener 这两个监听器实现的

LinearSnapHelper

SnapHelper 辅助 RecyclerView 滚动对齐的框架已经搭好了,子类只要根据对齐方式实现那三个抽象方法就可以了。以 LinearSnapHelper 为例,看它到底怎么实现 SnapHelper 的三个抽象方法,从而让 ItemView 滚动居中对齐:

calculateDistanceToFinalSnap

该方法是返回第二个传参对应的 view 到 RecyclerView 中间位置的距离,可以支持水平方向滚动和竖直方向滚动两个方向的计算。最主要的计算距离的这个方法 distanceToCenter()

可以看到,就是计算对应的 view 的中心坐标到 RecyclerView 中心坐标之间的距离,该距离就是此 view 需要滚动的距离。

findSnapView()

寻找 SnapView ,这里的目的坐标就是 RecyclerView 中间位置坐标,可以看到会根据 layoutManager 的布局方式(水平布局方式或者竖向布局方式)区分计算,但最终都是通过findCenterView()方法来找snapView的。

注释解释得很清楚,就不重复了。

findTargetSnapPosition()

RecyclerView 的 layoutManager 很灵活,有两种布局方式(横向布局和纵向布局),每种布局方式有两种布局方向(正向布局和反向布局)。这个方法在计算 targetPosition 的时候把布局方式和布局方向都考虑进去了。布局方式可以通过layoutManager.canScrollHorizontally()/layoutManager.canScrollVertically()来判断,布局方向就通过RecyclerView.SmoothScroller.ScrollVectorProvider这个接口中的computeScrollVectorForPosition()方法来判断。

所以 SnapHelper 为了适配 layoutManager 的各种情况,特意要求只有实现了 RecyclerView.SmoothScroller.ScrollVectorProvider 接口的 layoutManager 才能使用 SnapHelper 进行辅助滚动对齐。官方提供的 LinearLayoutManager、GridLayoutManager 和 StaggeredGridLayoutManager 都实现了这个接口,所以都支持 SnapHelper 。

这几个方法在计算位置的时候用的是 OrientationHelper 这个工具类,它是 LayoutManager 用于测量 child 的一个辅助类,可以根据 Layoutmanager 的布局方式和布局方向来计算得到 ItemView 的大小位置等信息。

从源码中可以看到findTargetSnapPosition()会先找到 fling 操作被触发时界面上的 snapView(因为findTargetSnapPosition()方法是在onFling()方法中被调用的),得到对应的 snapPosition ,然后通过estimateNextPositionDiffForFling()方法估算位置偏移量, snapPosition 加上位置偏移量就得到最终滚动结束时的位置,也就是 targetSnapPosition。

这里有一个点需要注意一下,就是在找 targetSnapPosition 之前是需要先找一个参考位置的,该参考位置就是 snapPosition 了。这是因为当前界面上不同的 ItemView 位置相差比较大,用 snapPosition 作参考位置,会使得参考位置加上位置偏移量得到的 targetSnapPosition 最接近目的坐标位置,从而让后续的坐标对齐调整更加自然。 

看下estimateNextPositionDiffForFling()方法怎么估算位置偏移量的:

可以看到就是用滚动总距离除以 itemview 的长度,从而估算得到需要滚动的 item 数量,此数值就是位置偏移量。而滚动距离是通过 SnapHelper 的calculateScrollDistance()方法得到的, ItemView 的长度是通过computeDistancePerChild()方法计算出来。

看下这两个方法:

可以发现computeDistancePerChild()方法也用总长度除以 ItemView 个数的方式来得到 ItemView 平均长度,并且也支持了 layoutManager 不同的布局方式和布局方向。

calculateScrollDistance()是 SnapHelper 中的方法,它使用到的 mGravityScroller 是一个在attachToRecyclerView()中初始化的 Scroller 对象,通过Scroller.fling()方法模拟 fling 操作,将 fling 的起点位置为设置为 0,此时得到的终点位置就是 fling 的距离。这个距离会有正负符号之分,表示滚动的方向。

现在明白了吧,LinearSnapHelper 的主要功能就是通过实现 SnapHelper 的三个抽象方法,从而实现辅助 RecyclerView 滚动 Item 对齐中心位置。

自定义SnapHelper

经过了以上分析,了解了 SnapHelper 的工作原理之后,自定义 SnapHelper 也就更加自如了。现在来看下 Google Play 主界面的效果。 

可以看到该效果是一个类似 Gallery 的横向列表滑动控件,很明显可以用 RecyclerView 来实现,而滚动后的 ItemView 是对齐 RecyclerView 的左边缘位置,这种对齐效果当仍不让就使用了 SnapHelper 来实现了。这里就主要讲下这个 SnapHelper 怎么实现的。

创建一个 GallerySnapHelper 继承 SnapHelper 实现它的三个抽象方法:

1、calculateDistanceToFinalSnap():计算 SnapView 当前位置与目标位置的距离。

2、findSnapView():找到当前时刻的 SnapView 。

3、findTargetSnapPosition(): 在触发 fling 时找到 targetSnapPosition。

这个方法跟 LinearSnapHelper 的实现基本是一样的。

就这样实现三个抽象方法之后看下效果:

发现基本能像 Google Play 那样进行对齐左侧边缘。但作为一个有理想有文化有追求的程序员,怎么可以那么容易满足呢?!极致才是最终的目标!没时间解释了,快上车!

目前的效果跟 Google Play 中的效果主要还有两个差异:

  1. 滚动速度明显慢于 Google Play 的横向列表滚动速度,导致滚动起来感觉比较拖沓,看起来不是很干脆的样子。

  2. Google Play 那个横向列表一次滚动的个数最多就是一页的 Item 个数,而目前的效果滑得比较快时会滚得很远。


其实这两个问题如果你理解了我上面所讲的 SnapHelper 的原理,解决起来就很容易了。

对于滚动速度偏慢的问题,由于这个 fling 过程是通过 SnapHelper 的 SmoothScroller 控制的,我们在分析创建 SmoothScroller 对象的时候就提到 SmoothScroller 的calculateSpeedPerPixel()方法是在定义滚动速度的,那复写 SnapHelper 的createSnapScroller()方法重新定义一个 SmoothScroller 不就可以了么?!

可以看到,代码跟 SnapHelper 里是一模一样的,就只是改了 MILLISECONDS_PER_INCH 这个数值而已,使得calculateSpeedPerPixel()返回值变小,从而让 SmoothScroller 的滚动速度更快。

对于一次滚动太多个 Item 的问题,就需要对他滚动的个数做下限制了。那在哪里对滚动的数量做限制呢?findTargetSnapPosition()方法里! 该方法的作用就是在寻找需要滚动到哪个位置的,不在这里还能在哪里?!直接看代码:

可以看到就是对估算出来的位置偏移量做下大小限制而已,就这么简单!

通过这样调整,效果已经跟 Google Play 基本一样了,我猜 Google Play 也是这样做的!看效果:

推荐阅读:


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存