大家好,本人是一个萌新android开发,最近对与RecyclerView搭配使用的SnapHelper非常感兴趣,本篇文章是记录了一些我对SnapHelper的研究与体会。
SnapHelper简介
SnapHelper是什么,能做什么是我最先感兴趣的东西,从官方文档看来SnapHelper是一个辅助RecyclerView滚动的辅助类,RecyclerView本身是一个滚动容器支持横向竖向多视图布局滚动,SnapHelper则可以辅助RecyclerView滚动结束时对其指定位置,例如ViewPager的效果,以及Google Play的效果。大白话就是在RecyclerView停止滚动时,通过SnapHelper辅助让其继续滚动到指定位置。

开始解析SnapHelper
SnapHelper本身是一个抽象类,Google官方给了两个实现类, LinearSnapHelper
以及PagerSnapHelper
,前者的效果是在RecyclerView滚动停止时对齐中间,其效果类似ViewPager但是一次可以滚动多页,另一个PagerSnapHelper
的话则是一次只能滚动一页。OK,我们明白了效果就带着疑问来看源码吧!
1.怎么样在停止滚动后对齐指定位置
2.LinearSnapHelper
和PagerSnapHelper
的区别
3.怎么自定义一个SnapHelper设置为我们想要的指定位置
我们先从入口开始
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
if (this.mRecyclerView != recyclerView) {
if (this.mRecyclerView != null) {
this.destroyCallbacks();
}
this.mRecyclerView = recyclerView;
if (this.mRecyclerView != null) {
this.setupCallbacks();
this.mGravityScroller = new Scroller(this.mRecyclerView.getContext(), new DecelerateInterpolator());
this.snapToTargetExistingView();
}
}
}
可以看到传入的RecyclerView会先判断是否不等于上一次传入的RecyclerView。如果不相等的话会先调用this.destroyCallbacks();
然后重新绑定新传入RecyclerView,依次调用了
this.setupCallbacks();
Scroller scroller = new Scroller(this.mRecyclerView.getContext(), new DecelerateInterpolator());
this.snapToTargetExistingView();
destroyCallbacks
this.mRecyclerView.removeOnScrollListener(this.mScrollListener);
this.mRecyclerView.setOnFlingListener((RecyclerView.OnFlingListener) null);
这个方法就是解除了RecyclerView的各种绑定,其中RecyclerView.OnFlingListener
看的比较陌生,经过查阅知道这个回调是在Fling事件后回掉,所谓的Fling事件我认为就是手指离开屏幕但是RecyclerView不是会立即停止,而是会根据惯性继续滚动一段距离,直到最后停止,从手指离开到最后停止的这一个完整的过程。
setupCallbacks
this.mRecyclerView.addOnScrollListener(this.mScrollListener);
this.mRecyclerView.setOnFlingListener(this);
这个方法很简单,绑定了事件
new Scroller
Scroller scroller = new Scroller(this.mRecyclerView.getContext(), new DecelerateInterpolator());
可以看到是初始化了一个Scroller具体作用么,我们现在还不知道,留着慢慢分析。
snapToTargetExistingView
void snapToTargetExistingView() {
if (this.mRecyclerView != null) {
LayoutManager layoutManager = this.mRecyclerView.getLayoutManager();
if (layoutManager != null) {
View snapView = this.findSnapView(layoutManager);
if (snapView != null) {
int[] snapDistance = this.calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
this.mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
}
}
}
这个方法里面的内容就比较多了,但是我看到了smoothScrollBy
说明在最初绑定的时候其实就调用过对齐方法。SnapHelper
本身是一个抽象类,里面的抽象方法分别是
@Nullable
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager var1, @NonNull View var2);
@Nullable
public abstract View findSnapView(LayoutManager var1);
public abstract int findTargetSnapPosition(LayoutManager var1, int var2, int var3);
在snapToTargetExistingView
中调用了findSnapView
和calculateDistanceToFinalSnap
我们来分析子类LinearSnapHelper
中的实现方法
findSnapView
public View findSnapView(LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return this.findCenterView(layoutManager, this.getVerticalHelper(layoutManager));
} else {
return layoutManager.canScrollHorizontally() ? this.findCenterView(layoutManager, this.getHorizontalHelper(layoutManager)) : null;
}
}
因为RecyclerView本身支持横向和竖向的滚动,所以有一个判断方法,但是可以看到不管是哪个方向,最后调用的都为findCenterView
方法
findCenterView
private View findCenterView(LayoutManager layoutManager, android.support.v7.widget.OrientationHelper helper) {
//当前屏幕上子View的数量
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
} else {
View closestChild = null;
int center;
//RecyclerView的clipToPadding是否为true
if (layoutManager.getClipToPadding()) {
//RecyclerView的paddingLeft+RecyclerView除去padding的实际宽度 / 2
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
//RecyclerView的宽度 / 2
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
for(int i = 0; i < childCount; ++i) {
View child = layoutManager.getChildAt(i);
//子view的中心位置
int childCenter = helper.getDecoratedStart(child) + helper.getDecoratedMeasurement(child) / 2;
int absDistance = Math.abs(childCenter - center);
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
}
大部分代码都加上注释了,OrientationHelper
是封装好的一个测量位置的工具类,感兴趣的同学可以自行看源码因为不涉及逻辑,我们这里就不分析了,继续看findCenterView
方法,先算出了RecyclerView的中心位置,然后一个循环算出最接近中心位置的View并返回,画了个图应改是比较清楚的了。

calculateDistanceToFinalSnap
分析这个方法前我们应该还记得在snapToTargetExistingView
中是怎么调用方法的吧,
View snapView = this.findSnapView(layoutManager);
if (snapView != null) {
int[] snapDistance = this.calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
this.mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
已经知道了findSnapView
方法的含义,再来看这个逻辑已经清晰了很多,以LinearSnapHelper
为例子,首先找到了离中心最近的View然后调用calculateDistanceToFinalSnap
返回了一个长度为2的数组,结合下面的smoothScrollBy
我们就已经能分析出来这个数组包含的肯定是一个横向x距离一个竖向的y距离,我们来看下具体的实现逻辑
public int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView) {
int[] out = new int[2];
if (layoutManager.canScrollHorizontally()) {
out[0] = this.distanceToCenter(layoutManager, targetView, this.getHorizontalHelper(layoutManager));
} else {
out[0] = 0;
}
if (layoutManager.canScrollVertically()) {
out[1] = this.distanceToCenter(layoutManager, targetView, this.getVerticalHelper(layoutManager));
} else {
out[1] = 0;
}
return out;
}
果然是这样的,如果可以横向滚动则计算横向的距离,竖向的也一样,我们再看看distanceToCenter
方法
private int distanceToCenter(@NonNull LayoutManager layoutManager, @NonNull View targetView, OrientationHelper helper) {
int childCenter = helper.getDecoratedStart(targetView) + helper.getDecoratedMeasurement(targetView) / 2;
int containerCenter;
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
return childCenter - containerCenter;
}
哈哈,出乎意外的简单嘛,我们用之前算出的中心View的中心距离减去整个RecycleView的中心距离并返回

阶段总结,回答问题一
至此我们已经分析了snapToTargetExistingView
方法的完整流程,可以小小的总结一下
findSnapView
是用来找到需要对齐的item,calculateDistanceToFinalSnap
则是用来计算滚动到对齐位置需要的具体偏移量,那么问题一的答案也是很明显了,就是在停止滚动后调用了,snapToTargetExistingView
,上代码!
private final OnScrollListener mScrollListener = new OnScrollListener() {
boolean mScrolled = false;
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == 0 && this.mScrolled) {
this.mScrolled = false;
SnapHelper.this.snapToTargetExistingView();
}
}
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (dx != 0 || dy != 0) {
this.mScrolled = true;
}
}
};
不出所料~

问题二,LinearSnapHelper
和PagerSnapHelper
的区别
LinearSnapHelper
和PagerSnapHelper
的区别其实就在于前者可以一次滚动多个item,我们前面也提过Fling事件,所以具体的区别肯定是在各自处理Fling的不同啦~开始撸代码,首先还是要看下SnapHelper
public boolean onFling(int velocityX, int velocityY) {
LayoutManager layoutManager = this.mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return false;
} else {
Adapter adapter = this.mRecyclerView.getAdapter();
if (adapter == null) {
return false;
} else {
//最小响应Fling的速率
int minFlingVelocity = this.mRecyclerView.getMinFlingVelocity();
return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity) && this.snapFromFling(layoutManager, velocityX, velocityY);
}
}
}
上面的代码很简单,就是判断下是否响应,重点在snapFromFling
snapFromFling
private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX, int velocityY) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
} else {
SmoothScroller smoothScroller = this.createScroller(layoutManager);
if (smoothScroller == null) {
return false;
} else {
int targetPosition = this.findTargetSnapPosition(layoutManager, velocityX, velocityY);
if (targetPosition == -1) {
return false;
} else {
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);
return true;
}
}
}
}
首先判断了layoutManager
是否实现了ScrollVectorProvider
接口,这个接口只有一个实现方法是用来判断布局方向的,系统提供的layoutManager
都是实现了该接口无需我们操心,后面有一个createScroller
我们看下代码
@Nullable
protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
return !(layoutManager instanceof ScrollVectorProvider) ? null : new LinearSmoothScroller(this.mRecyclerView.getContext()) {
protected void onTargetFound(View targetView, RecyclerView.State state, RecyclerView.SmoothScroller.Action action) {
if (SnapHelper.this.mRecyclerView != null) {
//算出对齐位置的偏移量
int[] snapDistances = SnapHelper.this.calculateDistanceToFinalSnap(SnapHelper.this.mRecyclerView.getLayoutManager(), targetView);
int dx = snapDistances[0];
int dy = snapDistances[1];
//计算减速滚动的时间
int time = this.calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
//改变滚动速率
action.update(dx, dy, time, this.mDecelerateInterpolator);
}
}
}
//1dp滚动需要的时间
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return 100.0F / (float) displayMetrics.densityDpi;
}
};
}
加上了一些注释,这里就不再过多分析其原理了,我们重点放在findTargetSnapPosition
看看LinearSnapHelper
是怎么实现的
public int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY) {
//判断LayoutManager是否实现ScrollVectorProvider接口
if (!(layoutManager instanceof ScrollVectorProvider)) {
return -1;
} else {
int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return -1;
} else {
//获取中心的View
View currentView = this.findSnapView(layoutManager);
if (currentView == null) {
return -1;
} else {
int currentPosition = layoutManager.getPosition(currentView);
if (currentPosition == -1) {
return -1;
} else {
ScrollVectorProvider vectorProvider = (ScrollVectorProvider) layoutManager;
//用来判断布局方向
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd == null) {
return -1;
} else {
int hDeltaJump;
//如果可以横向滚动
if (layoutManager.canScrollHorizontally()) {
hDeltaJump = this.estimateNextPositionDiffForFling(layoutManager, this.getHorizontalHelper(layoutManager), velocityX, 0);
//如果是方向布局则值取反
if (vectorForEnd.x < 0.0F) {
hDeltaJump = -hDeltaJump;
}
} else {
hDeltaJump = 0;
}
int vDeltaJump;
//如果可以竖向滚动
if (layoutManager.canScrollVertically()) {
vDeltaJump = this.estimateNextPositionDiffForFling(layoutManager, this.getVerticalHelper(layoutManager), 0, velocityY);
//如果是方向布局则值取反
if (vectorForEnd.y < 0.0F) {
vDeltaJump = -vDeltaJump;
}
} else {
vDeltaJump = 0;
}
//fling了多少item
int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
if (deltaJump == 0) {
return -1;
} else {
//加上最开始算到的中心view的position,得到的就是我们要滚动到的position
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
}
}
}
}
}
}
代码有些长,我们逐步分析,前面都是一些判断与取值已经加上了注释,我们来看看是怎么算出一次fling事件滚动多少item的,也就是estimateNextPositionDiffForFling
方法
private int estimateNextPositionDiffForFling(LayoutManager layoutManager, OrientationHelper helper, int velocityX, int velocityY) {
int[] distances = this.calculateScrollDistance(velocityX, velocityY);
float distancePerChild = this.computeDistancePerChild(layoutManager, helper);
if (distancePerChild <= 0.0F) {
return 0;
} else {
int distance = Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
return Math.round((float)distance / distancePerChild);
}
}
推了推我的黑框眼镜,亦可赛艇,继续分析calculateScrollDistance
和computeDistancePerChild
public int[] calculateScrollDistance(int velocityX, int velocityY) {
int[] outDist = new int[2];
this.mGravityScroller.fling(0, 0, velocityX, velocityY, -2147483648, 2147483647, -2147483648, 2147483647);
outDist[0] = this.mGravityScroller.getFinalX();
outDist[1] = this.mGravityScroller.getFinalY();
return outDist;
}
还记得我们最开始初始化了一个Scroller
么,原来是在这里用上了,传入我们的速率之后调用Scroller.getFinal
方法就能得到最终的滚动距离,也就是说calculateScrollDistance
方法返回的是滚动总距离,那么computeDistancePerChild
呢
private float computeDistancePerChild(LayoutManager layoutManager, OrientationHelper helper) {
View minPosView = null;
View maxPosView = null;
int minPos = Integer.MAX_VALUE;
int maxPos = Integer.MIN_VALUE;
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return 1.0F;
} else {
int start;
int pos;
for (start = 0; start < childCount; ++start) {
View child = layoutManager.getChildAt(start);
pos = layoutManager.getPosition(child);
if (pos != -1) {
//筛选到position最小的View
if (pos < minPos) {
minPos = pos;
minPosView = child;
}
//筛选到position最大的View
if (pos > maxPos) {
maxPos = pos;
maxPosView = child;
}
}
}
if (minPosView != null && maxPosView != null) {
//比对position最小的View和position最大的View的left
start = Math.min(helper.getDecoratedStart(minPosView), helper.getDecoratedStart(maxPosView));
//比对position最小的View和position最大的View的right
int end = Math.max(helper.getDecoratedEnd(minPosView), helper.getDecoratedEnd(maxPosView));
//总距离
pos = end - start;
if (pos == 0) {
return 1.0F;
} else {
//总距离除总数得到的当然就是平均距离啦~
return 1.0F * (float) pos / (float) (maxPos - minPos + 1);
}
} else {
return 1.0F;
}
}
}
这里理解起来还是比较简单的,这个方法就是返回了平均一个item的平均长度,那么我们回头看estimateNextPositionDiffForFling
也就非常好理解了
int distance = Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
return Math.round((float)distance / distancePerChild);
总距离除平均距离的得到的当然就是平均数量啦。

至此LinearSnapHelper
就分析完毕,相比起来PagerSnapHelper
就很简单啦,这里简单提下,在PagerSnapHelper
中是先获取中心View然后根据滚动方向,中心View的position加一或者减一,如果有这方面的问题的话欢迎私信本人~
总结一下流程
RecyclerView停止滚动的时候调用snapToTargetExistingView
方法,先获取需要对齐的ViewfindSnapView
再根据对齐View获取需要滚动的距离calculateDistanceToFinalSnap
onFling事件中判断当前的fling是否达到滚动的最小速率,然后调用snapFromFling
在其中的findTargetSnapPosition
方法获得fling后滚动到的position
调用smoothScroller.setTargetPosition(targetPosition)
进行滚动。
问题三,自定义SnapHelper
按照国际惯例,自定义一个上对齐的好啦~
public class TopSnapHelper extends SnapHelper {
private OrientationHelper mVerticalHelper;
@Nullable
@Override
public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View view) {
int[] out = new int[2];
out[0] = 0;
if (layoutManager.canScrollVertically()) {
out[1] = getVerticalHelper(layoutManager).getDecoratedStart(view);
} else {
out[1] = 0;
}
return out;
}
@Nullable
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findTopView(layoutManager, getVerticalHelper(layoutManager));
}
return null;
}
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return -1;
} else {
View mStartMostChildView = null;
if (layoutManager.canScrollVertically()) {
mStartMostChildView = this.findStartView(layoutManager, this.getVerticalHelper(layoutManager));
}
if (mStartMostChildView == null) {
return -1;
} else {
int centerPosition = layoutManager.getPosition(mStartMostChildView);
if (centerPosition == -1) {
return -1;
} else {
boolean forwardDirection;
if (layoutManager.canScrollHorizontally()) {
forwardDirection = velocityX > 0;
} else {
forwardDirection = velocityY > 0;
}
return (forwardDirection ? centerPosition + 1 : centerPosition);
}
}
}
}
private View findTopView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
} else {
LinearLayoutManager manager = (LinearLayoutManager) layoutManager;
int firstPosition = manager.findFirstVisibleItemPosition();
View firstView = manager.findViewByPosition(firstPosition);
if (firstView == null) return null;
int lastPosition = manager.findLastCompletelyVisibleItemPosition();
//滚动到最后不用对齐
if (lastPosition == manager.getItemCount()) return null;
int start = Math.abs(helper.getDecoratedStart(firstView));
if (start >= helper.getDecoratedMeasurement(firstView) / 2) {
return manager.findViewByPosition(firstPosition + 1);
}
return firstView;
}
}
private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
} else {
View closestChild = null;
int startest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; ++i) {
View child = layoutManager.getChildAt(i);
int childStart = helper.getDecoratedStart(child);
if (childStart < startest) {
startest = childStart;
closestChild = child;
}
}
return closestChild;
}
}
@NonNull
private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
if (this.mVerticalHelper == null || this.mVerticalHelper.mLayoutManager != layoutManager) {
this.mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
}
return this.mVerticalHelper;
}}

感谢
本文参考了让你明明白白的使用RecyclerView——SnapHelper详解
由于本人是一个新手android开发所以写的东西不太好比较啰嗦,希望可以对大家的开发起到一定的帮助。
网友评论