1
前言
在一些特定的场景下,如照片的浏览,卡片列表滑动浏览,我们希望当滑动停止时可以将当前的照片或者卡片停留在屏幕中央,以吸引用户的焦点。在 Android 中,我们可以使用RecyclerView + Snaphelper 来实现,SnapHelper 旨在支持 RecyclerView 的对齐方式,也就是通过计算对齐 RecyclerView 中 TargetView 的指定点或者容器中的任何像素点(包括前面说的显示在屏幕中央)。本篇文章将详细介绍 SnapHelper 的相关知识点。本文目录如下:
2
SnapHelper 介绍
Google 在 Android 24.2.0 的 support 包中添加了 SnapHelper,SnapHelper 是对RecyclerView 的拓展,结合 RecyclerView 使用,能很方便的做出一些炫酷的效果。SnapHelper 到底有什么功能呢?SnapHelper 旨在支持 RecyclerView 的对齐方式,也就是通过计算对齐 RecyclerView 中 TargetView 的指定点或者容器中的任何像素点。,可能有点不好理解,看了后文的效果和原理分析就好理解了。看一下文档介绍:
SnapHelper 继承自RecyclerView.OnFlingListener
,并实现了它的抽象方法onFling
, 支持 SnapHelper 的RecyclerView.LayoutManager
必须实现RecyclerView.SmoothScroller.ScrollVectorProvider
接口,或者你自己实现onFling(int,int)
方法手动处理。SnapHeper 有以下几个重要方法:
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。
3
LinearSnapHelper & PagerSnapHelper
上面讲了 SnapHelper 的几个重要的方法和作用,SnapHelper 是一个抽象类,要使用SnapHelper,需要实现它的几个方法。而 Google 内置了两个默认实现类,LinearSnapHelper
和PagerSnapHelper
,LinearSnapHelper 可以使 RecyclerView 的当前 Item 居中显示(横向和竖向都支持),PagerSnapHelper 看名字可能就能猜到,使RecyclerView 像ViewPager 一样的效果,每次只能滑动一页(LinearSnapHelper 支持快速滑动), PagerSnapHelper 也是 Item 居中对齐。接下来看一下使用方法和效果。
(1) LinearSnapHelper
LinearSnapHelper
使当前 Item 居中显示,常用场景是横向的 RecyclerView, 类似ViewPager 效果,但是又可以快速滑动(滑动多页)。代码如下:
LinearLayoutManager manager = new LinearLayoutManager(getContext()); manager.setOrientation(LinearLayoutManager.VERTICAL); mRecyclerView.setLayoutManager(manager);// 将SnapHelper attach 到RecyclrView LinearSnapHelper snapHelper = new LinearSnapHelper(); snapHelper.attachToRecyclerView(mRecyclerView);
代码很简单,new 一个 SnapHelper 对象,然后 Attach 到 RecyclerView 即可。
效果如下:
如上图所示,简单几行代码就可以用 RecyclerView 实现一个类似 ViewPager 的效果,并且效果更赞。可以快速滑动多页,当前页剧中显示,并且显示前一页和后一页的部分。如果使用 ViewPager 来做还是有点麻烦的。除了上面的效果外,如果你想要和 ViewPager 一样,限制一次只让它滑动一页,那么你就可以使用 PagerSnapHelper 了,接下来看一下PagerSnapHelper 的使用效果。
(2) PagerSnapHelper (在Android 25.1.0 support 包加入的)
PagerSnapHelper
的展示效果和LineSnapHelper
是一样的,只是 PagerSnapHelper 限制一次只能滑动一页,不能快速滑动。代码如下:
PagerSnapHelper snapHelper = new PagerSnapHelper();
snapHelper.attachToRecyclerView(mRecyclerView);
PagerSnapHelper效果如下:
上面展示的是 PagerSnapHelper 水平方向的效果,竖直方向的效果和 LineSnapHelper 竖直的方向的效果差不多,只是不能快速滑动,就不在介绍了,感兴趣的可以把它们的效果都试一下。
上面就是LineSnapHelper
和 PagerSnapHelper
的使用和效果展示,了解了它的使用方法和效果,接下来我们看一下它的实现原理。
4
SnapHelper原码分析
上面介绍了 SnapHelper 的使用,那么接下来我们来看一下 SnapHelper 到底是怎么实现的,走读一下源码:
(1) 入口方法,attachToRecyclerView
通过attachToRecyclerView
方法将 SnapHelper attach 到 RecyclerView,看一下这个方法做了哪些事情:
/**
*
* 1,首先判断attach的RecyclerView 和原来的是否是一样的,一样则返回,不一样则替换
*
* 2,如果不是同一个RecyclerView,将原来设置的回调全部remove或者设置为null
*
* 3,Attach的RecyclerView不为null,先2设置回调 滑动的回调和Fling操作的回调,
* 初始化一个Scroller 用于后面做滑动处理,然后调用snapToTargetExistingView
*
* */ 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(); } }
(2) snapToTargetExistingView :
这个方法用于第一次Attach到 RecyclerView 时对齐 TargetView,或者当 Scroll 被触发的时候和 fling 操作的时候对齐 TargetView 。在attachToRecyclerView
和onScrollStateChanged
中都调用了这个方法。
/**
*
* 1,判断RecyclerView 和LayoutManager是否为null
*
* 2,调用findSnapView 方法来获取需要对齐的目标View(这是个抽象方法,需要子类实现)
*
* 3,通过calculateDistanceToFinalSnap 获取x方向和y方向对齐需要移动的距离
*
* 4,最后通过RecyclerView 的smoothScrollBy 来移动对齐
**/ 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]); } }
(3) Filing 操作时对齐:
SnapHelper 继承了 RecyclerView.OnFlingListener,实现了 onFling 方法。
/**
* fling 回调方法,方法中调用了snapFromFling,真正的对齐逻辑在snapFromFling里
*/
@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 方法被fling 触发,用来帮助实现fling 时View对齐
*
*/ private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX, int velocityY) { // 首先需要判断LayoutManager 实现了ScrollVectorProvider 接口没有, //如果没有实现 ,则直接返回。 if (!(layoutManager instanceof ScrollVectorProvider)) { return false; } // 创建一个SmoothScroller 用来做滑动到指定位置 RecyclerView.SmoothScroller smoothScroller = createSnapScroller(layoutManager); if (smoothScroller == null) { return false; } // 根据x 和 y 方向的速度来获取需要对齐的View的位置,需要子类实现。 int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY); if (targetPosition == RecyclerView.NO_POSITION) { return false; } // 最终通过 SmoothScroller 来滑动到指定位置 smoothScroller.setTargetPosition(targetPosition); layoutManager.startSmoothScroll(smoothScroller); return true; }
其实通过上面的 3 个方法就实现了 SnapHelper 的对齐,只是有几个抽象方法是没有实现的,具体的对齐规则交给子类去实现。
接下来看一下 LinearSnapHelper 是怎么实现剧中对齐的:主要是实现了上面提到的三个抽象方法,findTargetSnapPosition
、calculateDistanceToFinalSnap
和findSnapView
。
(1) calculateDistanceToFinalSnap : 计算最终对齐要移动的距离,返回一个长度为 2 的int 数组 out,out[0] 为 x 方向移动的距离,out[1] 为 y 方向移动的距离。
@Override public int[] calculateDistanceToFinalSnap( @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { int[] out = new int[2]; // 如果是水平方向滚动的,则计算水平方向需要移动的距离,否则水平方向的移动距离为0 if (layoutManager.canScrollHorizontally()) { out[0] = distanceToCenter(layoutManager, targetView, getHorizontalHelper(layoutManager)); } else { out[0] = 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; }
(2) findSnapView: 找到要对齐的 View
// 找到要对齐的目标View, 最终的逻辑在findCenterView 方法里// 规则是:循环LayoutManager的所有子元素,计算每个 childView的//中点距离Parent 的中点,找到距离最近的一个,就是需要居中对齐的目标View @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; }
(3) findTargetSnapPosition : 找到需要对齐的目标 View 的的 Position
@Override public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {...// 前面代码省略 int vDeltaJump, hDeltaJump; // 如果是水平方向滚动的列表,估算出水平方向SnapHelper响应fling //对齐要滑动的position和当前position的差,否则,水平方向滚动的差值为0. if (layoutManager.canScrollHorizontally()) { hDeltaJump = estimateNextPositionDiffForFling(layoutManager, getHorizontalHelper(layoutManager), velocityX, 0); if (vectorForEnd.x < 0) { hDeltaJump = -hDeltaJump; } } else { hDeltaJump = 0; } // 如果是竖直方向滚动的列表,估算出竖直方向SnapHelper响应fling //对齐要滑动的position和当前position的差,否则,竖直方向滚动的差值为0. if (layoutManager.canScrollVertically()) { vDeltaJump = estimateNextPositionDiffForFling(layoutManager, getVerticalHelper(layoutManager), 0, velocityY); if (vectorForEnd.y < 0) { vDeltaJump = -vDeltaJump; } } else { vDeltaJump = 0; } // 最终要滑动的position 就是当前的Position 加上上面算出来的差值。//后面代码省略...}
以上就分析了 LinearSnapHelper 实现滑动的时候居中对齐和 fling 时居中对齐的源码。整个流程还是比较简单清晰的,就是涉及到比较多的位置计算比较麻烦。熟悉了它的实现原理,从上面我们知道,SnapHelper 里面实现了对齐的流程,但是怎么对齐的规则就交给子类去处理了,比如 LinearSnapHelper 实现了居中对齐,PagerSnapHelper 实现了居中对齐,并且限制只能一次滑动一页。那么我们也可以继承它来实现我们自己的 SnapHelper,接下来看一下自己实现一个 SnapHelper。
5
自定义 SnapHelper
上面分析了 SnapHelper 的流程,那么这节我们来自定义一个 SnapHelper , LinearSnapHelper 实现了居中对齐,那么我们来试着实现 Target View 开始对齐。 当然了,我们不用去继承 SnapHelper,既然 LinearSnapHelper 实现了居中对齐,那么我们只要更改一下对齐的规则就行,更改为开始对齐(计算目标 View到 Parent start 要滑动的距离),其他的逻辑和 LinearSnapHelper 是一样的。因此我们选择继承 LinearSnapHelper,具体代码如下:
/**
* Created by zhouwei on 17/3/30.
*/
public class StartSnapHelper extends LinearSnapHelper { private OrientationHelper mHorizontalHelper, mVerticalHelper; @Nullable @Override public int[] calculateDistanceToFinalSnap(RecyclerView.LayoutManager layoutManager, View targetView) { int[] out = new int[2]; if (layoutManager.canScrollHorizontally()) { out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager)); } else { out[0] = 0; } if (layoutManager.canScrollVertically()) { out[1] = distanceToStart(targetView, getVerticalHelper(layoutManager)); } else { out[1] = 0; } return out; } private int distanceToStart(View targetView, OrientationHelper helper) { return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding(); } @Nullable @Override public View findSnapView(RecyclerView.LayoutManager layoutManager) { if (layoutManager instanceof LinearLayoutManager) { if (layoutManager.canScrollHorizontally()) { return findStartView(layoutManager, getHorizontalHelper(layoutManager)); } else { return findStartView(layoutManager, getVerticalHelper(layoutManager)); } } return super.findSnapView(layoutManager); } private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) { if (layoutManager instanceof LinearLayoutManager) { int firstChild = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition(); //需要判断是否是最后一个Item,如果是最后一个则不让对齐,以免出现最后一个显示不完全。 boolean isLastItem = ((LinearLayoutManager) layoutManager) .findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1; if (firstChild == RecyclerView.NO_POSITION || isLastItem) { return null; } View child = layoutManager.findViewByPosition(firstChild); if (helper.getDecoratedEnd(child) >= helper.getDecoratedMeasurement(child) / 2 && helper.getDecoratedEnd(child) > 0) { return child; } else { if (((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1) { return null; } else { return layoutManager.findViewByPosition(firstChild + 1); } } } return super.findSnapView(layoutManager); } private OrientationHelper getHorizontalHelper( @NonNull RecyclerView.LayoutManager layoutManager) { if (mHorizontalHelper == null) { mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager); } return mHorizontalHelper; } private OrientationHelper getVerticalHelper(RecyclerView.LayoutManager layoutManager) { if (mVerticalHelper == null) { mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager); } return mVerticalHelper; }
}
使用的时候,更改为使用 StartSnapHelper,代码如下:
StartSnapHelper snapHelper = new StartSnapHelper();
snapHelper.attachToRecyclerView(mRecyclerView);
效果如下:
以上就实现了一个 Start 对齐的效果,此外,在 Github 上发现一个实现了好几种 Snap 效果的库,比如,start 对齐、end 对齐,top 对齐等等。有兴趣的可以去弄来玩一下,地址:[Snap 效果库]。(https://github.com/rubensousa/RecyclerViewSnap)
6
总结
SnapHelper 是对 RecyclerView 的一个扩展,可以很方便的实现类似 ViewPager 的效果,比ViewPager 效果更好,当我们要实现卡片式的浏览或者图库照片浏览时,使用RecyclerView + SnapHelper 的效果要比 ViewPager 的效果好很多。因此掌握 SnapHelper 的使用技巧,能帮助我们方便的实现一些滑动交互效果,以上就是对 Snapuhelper 的总结,如有问题,欢迎留言交流。本文 Demo 已上传 Github AndroidTrainingSimples https://github.com/pinguo-zhouwei/AndroidTrainingSimples
参考:
Using SnapHelper in RecyclerView
https://blog.mindorks.com/using-snaphelper-in-recyclerview-fc616b6833e8