在开发过程中,对齐效果是一个很常见的功能:比如我们使用ViewPager,或者是使用画廊效果的FancyCoverFlow,都无一例外的要求某一个Item居中对齐。比如看看Google Play,它实现了滑动停止后Item居中的效果,你可能会通过计算得到最接近RecyclerView中间轴位置的Item,然后计算得到偏移量,最后通过scroll滚动过去来实现。这个思路是没有问题的,但是谷歌已经帮你做好了这些事,并且让你一句话就能实现这个效果。听到这个消息,你是不是觉得有点崩溃?
这个效果就是通过SnapHelper
来实现的。很多人都不知道SnapHelper
的存在,所以有些很常见的效果往往会花费好大力气来自己实现。我们先从实例来了解SnapHelper
如何使用,再从源码分析SnapHelper
是怎样完成对齐效果的
SnapHelper简介
在阅读代码之前,先简单介绍一下SnapHelper
。我们可以在appcompat-v7
包中找到SnapHelper
,过老的版本里面可能会没有。SnapHelper
是一个抽象类,官方提供了LinearSnapHelper
和PagerSnapHelper
两个子类,LinearSnapHelper
可以让RecyclerView在滚动停止时让Item居中对齐,而PagerSnapHelper
可以使RecyclerView像ViewPager一样一次只能滑一页,并且居中对齐。
来看看如何调用
LinearSnapHelper helper = new LinearSnapHelper();
helper.attachToRecyclerView(mRecyclerView);
就这么简单,是不是很神奇
原理
SnapHelper
先处理得到了要滚动到的位置,待滚动完成之后进行对齐偏移量的计算,进而滚动到所对齐的位置。这一系列的计算判断过程由三个必须要实现的方法calculateDistanceToFinalSnap()
、findSnapView()
和findTargetSnapPosition()
来完成
calculateDistanceToFinalSnap()
:计算targetView
的坐标与需要对齐位置的坐标之间的距离。这个方法返回长度为2的int数组,分别对应x轴和y轴方向上的距离
findSnapView()
:代表需要对齐的目标View
findTargetSnapPosition()
:代表要滚动到的具体Item的索引,滚到第0个这个值就是0,滚到第五个这个值就是5
SnapHelper流程调用顺序
刚才我们知道要想实现对齐功能,只要代码中调用attachToRecyclerView()
即可,所以我们先进入这个方法里。这里有一个重要方法snapToTargetExistingView()
,其中通过calculateDistanceToFinalSnap()
计算得到偏移量,从而将findSnapView()
所得到的SnapView
移动到指定位置
public 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();
}
}
void 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]);
}
}
setupCallbacks()
方法将RecyclerView.OnScrollListener
与RecyclerView.OnFlingListener
绑定到当前的RecyclerView中
private void setupCallbacks() throws IllegalStateException {
if (mRecyclerView.getOnFlingListener() != null) {
throw new IllegalStateException("An instance of OnFlingListener already set.");
}
mRecyclerView.addOnScrollListener(mScrollListener);
mRecyclerView.setOnFlingListener(this);
}
RecyclerView在惯性滚动的时候可以调用snapFromFling()
平滑滚动到指定的索引位置,这个指定位置由findTargetSnapPosition()
给出
@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);
}
private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
}
SmoothScroller smoothScroller = createScroller(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;
}
在滑动结束之后RecyclerView调用snapToTargetExistingView()
调整位置
private 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;
}
}
};
OK,SnapHelper源码就这么多,了解三个必须要实现的方法在流程中的调用位置之后我们进入LinearSnapHelper
LinearSnapHelper源码解读
以LinearSnapHelper为例,来看看它到底怎么通过实现SnapHelper的三个抽象方法,从而让ItemView居中对齐的
首先来到findTargetSnapPosition()
方法,先是一系列的RecyclerView.NO_POSITION
。当你配置RecyclerView有问题的时候才会执行这些,比如layoutManager
没有实现ScrollVectorProvider
,item
的个数是0,findSnapView()
不存在或是不在当前可见范围内,无法判断layoutmanager
是正向还是反向的等。官方提供的LinearLayoutManager、GridLayoutManager和StaggeredGridLayoutManager都实现了ScrollVectorProvider接口,所以都支持SnapHelper。
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;
}
findSnapView()
就是获取当前待调整的那个SnapView。这里,在找到RecyclerView的中心点之后,最接近中心点的那个View就是对齐所用的SnapView。
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;
}
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;
}
随后就是通过estimateNextPositionDiffForFling()
得到要位移Item的个数。
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;
}
进入estimateNextPositionDiffForFling()
方法,里面有两个方法calculateScrollDistance()
与computeDistancePerChild()
,分别对应惯性滑动时总共需要滑动的距离与每一个Item可以滚动的最大距离。通过这两个数值的相除,得到大致要滚动多少Item数量
private 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);
}
注意这里的fling()
操作,通过X和Y的加速度,将fling()
的起点位置设置为0,此时得到的终点位置就是fling()
的距离。这个距离会有正负之分,表示滚动的方向。这个在惯性滚动上面或许可以给你带来新的启发
public int[] calculateScrollDistance(int velocityX, int velocityY) {
int[] outDist = new int[2];
mGravityScroller.fling(0, 0, velocityX, velocityY,
Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
outDist[0] = mGravityScroller.getFinalX();
outDist[1] = mGravityScroller.getFinalY();
return outDist;
}
这里就是通过左右或者上下两个极限的View的间距,获取每个的平均可移动数值。注意这里,每个Item的宽或高大小必须是一致的。
getDecoratedStart()
:该View的左边距偏移量,这个值在计算时将它的decoration以及margin包含在一起计算获取
getDecoratedEnd()
:该View的右边距偏移量,这个值在计算时将它的decoration以及margin包含在一起计算获取
private float computeDistancePerChild(RecyclerView.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 INVALID_DISTANCE;
}
for (int i = 0; i < childCount; i++) {
View child = layoutManager.getChildAt(i);
final int pos = layoutManager.getPosition(child);
if (pos == RecyclerView.NO_POSITION) {
continue;
}
if (pos < minPos) {
minPos = pos;
minPosView = child;
}
if (pos > maxPos) {
maxPos = pos;
maxPosView = child;
}
}
if (minPosView == null || maxPosView == null) {
return INVALID_DISTANCE;
}
int start = Math.min(helper.getDecoratedStart(minPosView),
helper.getDecoratedStart(maxPosView));
int end = Math.max(helper.getDecoratedEnd(minPosView),
helper.getDecoratedEnd(maxPosView));
int distance = end - start;
if (distance == 0) {
return INVALID_DISTANCE;
}
return 1f * distance / ((maxPos - minPos) + 1);
}
再次回到findTargetSnapPosition()
方法中,deltaJump
加上当前显示的第一个View的索引值,得到最终滚动到的View的索引值
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;
最后来到calculateDistanceToFinalSnap()
,通过计算获取需要滚动的距离。这个值是距离中心点最近的位置
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;
}
private 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;
}
好啦,源码就分析到这里了。你现在可以自己试着来定义一个对齐效果了
最后来介绍一个大神写的3k+star的项目:RecyclerViewSnap,它使用了官方的SnapHelper
去完成相应的左右上下对齐功能。代码也不复杂,自行走读一下吧