Android SnapHelper 源码分析
01.SnapHelper简单介绍
1.1 SnapHelper作用
- 在某些场景下,卡片列表滑动浏览
1.2 SnapHelper类分析
- 查阅可知,SnapHelper继承自RecyclerView.OnFlingListener,并且重写了onFling方法,这个类代码并不多,下面会对重要方法一一解析。
- 支持SnapHelper的RecyclerView.LayoutManager必须实现的方式:
- RecyclerView.SmoothScroller.ScrollVectorProvider接口
- 或者自己实现onFling(int,int)方法手动处理逻辑。
- 支持SnapHelper的RecyclerView.LayoutManager必须实现的方式:
- SnapHelper类重要的方法
- attachToRecyclerView: 将SnapHelper attach 到指定的RecyclerView 上。
- calculateDistanceToFinalSnap:复写这个方法计算对齐到TargetView或容器指定点的距离,这是一个抽象方法,由子类自己实现,返回的是一个长度为2的int 数组out,out[0]是x方向对齐要移动的距离,out[1]是y方向对齐要移动的距离。
- calculateScrollDistance: 根据每个方向给定的速度估算滑动的距离,用于Fling 操作。
- findSnapView:提供一个指定的目标View 来对齐,抽象方法,需要子类实现
- findTargetSnapPosition:提供一个用于对齐的Adapter 目标position,抽象方法,需要子类自己实现。
- onFling:根据给定的x和 y 轴上的速度处理Fling。
- 什么是Fling操作
- 手指在屏幕上滑动 RecyclerView然后松手,RecyclerView中的内容会顺着惯性继续往手指滑动的方向继续滚动直到停止,这个过程叫做 Fling 。 Fling 操作从手指离开屏幕瞬间被触发,在滚动停止时结束。
1.3 LinearSnapHelper类分析
LinearSnapHelper 使当前Item居中显示,常用场景是横向的RecyclerView,类似ViewPager效果,但是又可以快速滑动(滑动多页)。
最简单的使用就是,如下代码
- 几行代码就可以用RecyclerView实现一个类似ViewPager的效果,并且效果还不错。可以快速滑动多页,当前页剧中显示,并且显示前一页和后一页的部分。
1
2
3
4
5
6
7
8
9
10private void initRecyclerView() {
LinearLayoutManager manager = new LinearLayoutManager(this);
manager.setOrientation(LinearLayoutManager.HORIZONTAL);
mRecyclerView.setLayoutManager(manager);
LinearSnapHelper snapHelper = new LinearSnapHelper();
snapHelper.attachToRecyclerView(mRecyclerView);
SnapAdapter adapter = new SnapAdapter(this);
mRecyclerView.setAdapter(adapter);
adapter.addAll(getData());
}
1.4 PagerSnapHelper类分析
PagerSnapHelper看名字可能就能猜到,使RecyclerView像ViewPager一样的效果,每次只能滑动一页(LinearSnapHelper支持快速滑动), PagerSnapHelper也是Item居中对齐。
最简单的使用就是,如下代码
1
2
3
4
5
6
7
8
9
10private void initRecyclerView() {
LinearLayoutManager manager = new LinearLayoutManager(this);
manager.setOrientation(LinearLayoutManager.HORIZONTAL);
mRecyclerView.setLayoutManager(manager);
PagerSnapHelper snapHelper = new PagerSnapHelper();
snapHelper.attachToRecyclerView(mRecyclerView);
SnapAdapter adapter = new SnapAdapter(this);
mRecyclerView.setAdapter(adapter);
adapter.addAll(getData());
}
02.SnapHelper源码分析
2.1 attachToRecyclerView入口方法
通过attachToRecyclerView方法将SnapHelper attach 到RecyclerView,看一下这个方法的源代码
- 如果SnapHelper之前已经附着到此RecyclerView上,则不用进行任何操作
- 如果SnapHelper之前附着的RecyclerView和现在的不一致,就将原来设置的回调全部remove或者设置为null
- 然后更新RecyclerView对象引用,Attach的RecyclerView不为null,设置回调Callback,主要包括滑动的回调和Fling操作的回调,初始化一个Scroller 用于后面做滑动处理,然后调用snapToTargetExistingView
- 大概流程就是:在attachToRecyclerView()方法中会清掉SnapHelper之前保存的RecyclerView对象的回调(如果有的话),对新设置进来的RecyclerView对象设置回调,然后初始化一个Scroller对象,最后调用snapToTargetExistingView()方法对SnapView进行对齐调整。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
throws IllegalStateException {
if (mRecyclerView == recyclerView) {
return; // nothing to do
}
if (mRecyclerView != null) {
destroyCallbacks();
}
mRecyclerView = recyclerView;
if (mRecyclerView != null) {
setupCallbacks();
mGravityScroller = new Scroller(mRecyclerView.getContext(),
new DecelerateInterpolator());
snapToTargetExistingView();
}
}接着看看setupCallbacks()源码
- 上面已经说了,滑动的回调和Fling操作的回调
1
2
3
4
5
6
7private void setupCallbacks() throws IllegalStateException {
if (mRecyclerView.getOnFlingListener() != null) {
throw new IllegalStateException("An instance of OnFlingListener already set.");
}
mRecyclerView.addOnScrollListener(mScrollListener);
mRecyclerView.setOnFlingListener(this);
}接着看看snapToTargetExistingView()方法
- 这个方法用于第一次Attach到RecyclerView时对齐TargetView,或者当Scroll被触发的时候和fling操作的时候对齐TargetView 。
- 判断RecyclerView 和LayoutManager是否为null,接着调用findSnapView 方法来获取需要对齐的目标View,注意:这是个抽象方法,需要子类实现
- 通过calculateDistanceToFinalSnap 获取x方向和y方向对齐需要移动的距离
- 最后如果需要滚动的距离不是为0,就调用smoothScrollBy方法使RecyclerView滚动相应的距离
- 注意:RecyclerView.smoothScrollBy()这个方法的作用就是根据参数平滑滚动RecyclerView的中的ItemView相应的距离。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void snapToTargetExistingView() {
if (mRecyclerView == null) {
return;
}
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return;
}
View snapView = findSnapView(layoutManager);
if (snapView == null) {
return;
}
int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}然后来看一下mScrollListener监听里面做了什么
- 该滚动监听器的实现很简单,只是在正常滚动停止的时候调用了snapToTargetExistingView()方法对targetView进行滚动调整,以确保停止的位置是在对应的坐标上,这就是RecyclerView添加该OnScrollListener的目的。
- mScrolled为true表示之前进行过滚动,newState为SCROLL_STATE_IDLE状态表示滚动结束停下来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private final RecyclerView.OnScrollListener mScrollListener =
new RecyclerView.OnScrollListener() {
boolean mScrolled = false;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
mScrolled = false;
snapToTargetExistingView();
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (dx != 0 || dy != 0) {
mScrolled = true;
}
}
};
2.2 SnapHelper的抽象方法
calculateDistanceToFinalSnap抽象方法
- 计算最终对齐要移动的距离
- 计算二个参数对应的 ItemView 当前的坐标与需要对齐的坐标之间的距离。该方法返回一个大小为 2 的 int 数组,分别对应out[0] 为 x 方向移动的距离,out[1] 为 y 方向移动的距离。
1
2
3
4@SuppressWarnings("WeakerAccess")
@Nullable
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager,
@NonNull View targetView);- 计算最终对齐要移动的距离
findSnapView抽象方法
- 找到要对齐的View
- 该方法会找到当前 layoutManager 上最接近对齐位置的那个 view ,该 view 称为 SanpView ,对应的 position 称为 SnapPosition 。如果返回 null ,就表示没有需要对齐的 View ,也就不会做滚动对齐调整。
1
2
3@SuppressWarnings("WeakerAccess")
@Nullable
public abstract View findSnapView(LayoutManager layoutManager);- 找到要对齐的View
findTargetSnapPosition抽象方法
- 找到需要对齐的目标View的的Position。
- 更加详细一点说就是该方法会根据触发 Fling 操作的速率(参数 velocityX 和参数 velocityY )来找到 RecyclerView 需要滚动到哪个位置,该位置对应的 ItemView 就是那个需要进行对齐的列表项。我们把这个位置称为 targetSnapPosition ,对应的 View 称为 targetSnapView 。如果找不到 targetSnapPosition ,就返回RecyclerView.NO_POSITION 。
1
2public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX,
int velocityY);- 找到需要对齐的目标View的的Position。
2.3 onFling方法源码分析
SnapHelper继承了 RecyclerView.OnFlingListener,实现了onFling方法。
- 获取RecyclerView要进行fling操作需要的最小速率,为啥呢?因为只有超过该速率,ItemView才会有足够的动力在手指离开屏幕时继续滚动下去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14@Override
public boolean onFling(int velocityX, int velocityY) {
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return false;
}
RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
if (adapter == null) {
return false;
}
int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
&& snapFromFling(layoutManager, velocityX, velocityY);
}接着看看snapFromFling方法源代码,就是通过该方法实现平滑滚动并使得在滚动停止时itemView对齐到目的坐标位置
- 首先layoutManager必须实现ScrollVectorProvider接口才能继续往下操作
- 然后通过createSnapScroller方法创建一个SmoothScroller,这个东西是一个平滑滚动器,用于对ItemView进行平滑滚动操作
- 根据x和y方向的速度来获取需要对齐的View的位置,需要子类实现
- 最终通过 SmoothScroller 来滑动到指定位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
}
RecyclerView.SmoothScroller smoothScroller = createSnapScroller(layoutManager);
if (smoothScroller == null) {
return false;
}
int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
if (targetPosition == RecyclerView.NO_POSITION) {
return false;
}
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);
return true;
}- 总结一下可知:snapFromFling()方法会先判断layoutManager是否实现了ScrollVectorProvider接口,如果没有实现该接口就不允许通过该方法做滚动操作。接下来就去创建平滑滚动器SmoothScroller的一个实例,layoutManager可以通过该平滑滚动器来进行滚动操作。SmoothScroller需要设置一个滚动的目标位置,将通过findTargetSnapPosition()方法来计算得到的targetSnapPosition给它,告诉滚动器要滚到这个位置,然后就启动SmoothScroller进行滚动操作。
接着看下createSnapScroller这个方法源码
- 先判断layoutManager是否实现了ScrollVectorProvider这个接口,没有实现该接口就不创建SmoothScroller
- 这里创建一个LinearSmoothScroller对象,然后返回给调用函数,也就是说,最终创建出来的平滑滚动器就是这个LinearSmoothScroller
- 在创建该LinearSmoothScroller的时候主要考虑两个方面:
- 第一个是滚动速率,由calculateSpeedPerPixel()方法决定;
- 第二个是在滚动过程中,targetView即将要进入到视野时,将匀速滚动变换为减速滚动,然后一直滚动目的坐标位置,使滚动效果更真实,这是由onTargetFound()方法决定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24@Nullable
protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return null;
}
return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator);
}
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
};
}
03.LinearSnapHelper源码分析
3.1 LinearSnapHelper实现功能
- LinearSnapHelper实现了SnapHelper,并且实现SnapHelper的三个抽象方法,从而让ItemView滚动居中对齐。那么具体怎么做到呢?
3.2 calculateDistanceToFinalSnap()方法源码
calculateDistanceToFinalSnap源码如下所示
- 如果是水平方向滚动的,则计算水平方向需要移动的距离,否则水平方向的移动距离为0
- 如果是竖直方向滚动的,则计算竖直方向需要移动的距离,否则竖直方向的移动距离为0
- distanceToCenter方法主要作用是:计算水平或者竖直方向需要移动的距离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19@Override
public int[] calculateDistanceToFinalSnap(
@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
int[] out = new int[2];
if (layoutManager.canScrollHorizontally()) {
out[0] = distanceToCenter(layoutManager, targetView,
getHorizontalHelper(layoutManager));
} else {
out[0] = 0;
}
if (layoutManager.canScrollVertically()) {
out[1] = distanceToCenter(layoutManager, targetView,
getVerticalHelper(layoutManager));
} else {
out[1] = 0;
}
return out;
}接着看看distanceToCenter方法
- 计算对应的view的中心坐标到RecyclerView中心坐标之间的距离
- 首先是找到targetView的中心坐标
- 接着也就是找到容器【RecyclerView】的中心坐标
- 两个中心坐标的差值就是targetView需要滚动的距离
1
2
3
4
5
6
7
8
9
10
11
12private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
@NonNull View targetView, OrientationHelper helper) {
final int childCenter = helper.getDecoratedStart(targetView)
+ (helper.getDecoratedMeasurement(targetView) / 2);
final int containerCenter;
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
return childCenter - containerCenter;
}
3.3 findSnapView()方法源码
也就是找到要对齐的View
- 根据layoutManager的布局方式(水平布局方式或者竖向布局方式)区分计算,但最终都是通过findCenterView()方法来找snapView的。
1
2
3
4
5
6
7
8
9@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findCenterView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
return findCenterView(layoutManager, getHorizontalHelper(layoutManager));
}
return null;
}接着看看findCenterView方法源代码
- 查询当前是否支持垂直滚动还是横向滚动
- 循环LayoutManager的所有子元素,计算每个 childView的中点距离Parent 的中点,找到距离最近的一个,就是需要居中对齐的目标View
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31@Nullable
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
View closestChild = null;
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childCenter = helper.getDecoratedStart(child)
+ (helper.getDecoratedMeasurement(child) / 2);
int absDistance = Math.abs(childCenter - center);
/** if child center is closer than previous closest, set it as closest **/
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
3.4 findTargetSnapPosition()方法源码
LinearSnapHelper实现了SnapHelper,来看一下在findTargetSnapPosition操作了什么
- 如果是水平方向滚动的列表,估算出水平方向SnapHelper响应fling,对齐要滑动的position和当前position的差,否则,水平方向滚动的差值为0
- 如果是竖直方向滚动的列表,估算出竖直方向SnapHelper响应fling,对齐要滑动的position和当前position的差,否则,竖直方向滚动的差值为0
- 这个方法在计算targetPosition的时候把布局方式和布局方向都考虑进去了。布局方式可以通过layoutManager.canScrollHorizontally()/layoutManager.canScrollVertically()来判断,布局方向就通过RecyclerView.SmoothScroller.ScrollVectorProvider这个接口中的computeScrollVectorForPosition()方法来判断。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return RecyclerView.NO_POSITION;
}
final int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return RecyclerView.NO_POSITION;
}
final View currentView = findSnapView(layoutManager);
if (currentView == null) {
return RecyclerView.NO_POSITION;
}
final int currentPosition = layoutManager.getPosition(currentView);
if (currentPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION;
}
RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
(RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
// deltaJumps sign comes from the velocity which may not match the order of children in
// the LayoutManager. To overcome this, we ask for a vector from the LayoutManager to
// get the direction.
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd == null) {
// cannot get a vector for the given position.
return RecyclerView.NO_POSITION;
}
int vDeltaJump, hDeltaJump;
if (layoutManager.canScrollHorizontally()) {
hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), velocityX, 0);
if (vectorForEnd.x < 0) {
hDeltaJump = -hDeltaJump;
}
} else {
hDeltaJump = 0;
}
if (layoutManager.canScrollVertically()) {
vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getVerticalHelper(layoutManager), 0, velocityY);
if (vectorForEnd.y < 0) {
vDeltaJump = -vDeltaJump;
}
} else {
vDeltaJump = 0;
}
int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
if (deltaJump == 0) {
return RecyclerView.NO_POSITION;
}
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
3.5 支持哪些LayoutManager
- SnapHelper为了适配layoutManager的各种情况,特意要求只有实现了RecyclerView.SmoothScroller.ScrollVectorProvider接口的layoutManager才能使用SnapHelper进行辅助滚动对齐。官方提供的LinearLayoutManager、GridLayoutManager和StaggeredGridLayoutManager都实现了这个接口,所以都支持SnapHelper。
3.6 OrientationHelper类
如何创建OrientationHelper对象呢?如下所示
- 比如,上面三个抽象方法都使用到了这个类,这个类是干嘛的?
- 计算位置的时候用的是OrientationHelper这个工具类,它是LayoutManager用于测量child的一个辅助类,可以根据Layoutmanager的布局方式和布局方向来计算得到ItemView的大小位置等信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16@NonNull
private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
if (mVerticalHelper == null || mVerticalHelper.mLayoutManager != layoutManager) {
mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
}
return mVerticalHelper;
}
@NonNull
private OrientationHelper getHorizontalHelper(
@NonNull RecyclerView.LayoutManager layoutManager) {
if (mHorizontalHelper == null || mHorizontalHelper.mLayoutManager != layoutManager) {
mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
}
return mHorizontalHelper;
}
3.7 estimateNextPositionDiffForFling计算偏移量
如下所示
- 首先,计算滚动的总距离,这个距离受到触发fling时的速度的影响,得到一个distances数组
- 然后计算每个ItemView的长度
- 根据是横向布局还是纵向布局,来取对应布局方向上的滚动距离
- 总结大概流程就是:用滚动总距离除以itemview的长度,从而估算得到需要滚动的item数量,此数值就是位置偏移量。而滚动距离是通过SnapHelper的calculateScrollDistance()方法得到的,ItemView的长度是通过computeDistancePerChild()方法计算出来。
1
2
3
4
5
6
7
8
9
10
11private int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper, int velocityX, int velocityY) {
int[] distances = calculateScrollDistance(velocityX, velocityY);
float distancePerChild = computeDistancePerChild(layoutManager, helper);
if (distancePerChild <= 0) {
return 0;
}
int distance =
Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
return (int) Math.round(distance / distancePerChild);
}