Android 中使用 RecyclerView + SnapHelper 实现类似 ViewPager 效果

yylllld 8年前
   <p style="text-align:center"><img src="https://simg.open-open.com/show/822abedc83d02ab37631f751d8b35909.jpg"></p>    <h2>1 前言</h2>    <p>在 一些特定的场景下,如照片的浏览,卡片列表滑动浏览,我们希望当滑动停止时可以将当前的照片或者卡片停留在屏幕中央,以吸引用户的焦点。在 Android 中,我们可以使用RecyclerView + Snaphelper 来实现,SnapHelper 旨在支持 RecyclerView 的对齐方式,也就是通过计算对齐 RecyclerView 中 TargetView 的指定点或者容器中的任何像素点(包括前面说的显示在屏幕中央)。本篇文章将详细介绍 SnapHelper 的相关知识点。本文目录如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a561fea3a2810c09be5391111345a205.jpg"></p>    <h2>2 SnapHelper 介绍</h2>    <p>Google 在 Android 24.2.0 的 support 包中添加了 SnapHelper,SnapHelper 是对RecyclerView 的拓展,结合 RecyclerView 使用,能很方便的做出一些炫酷的效果。SnapHelper 到底有什么功能呢? SnapHelper 旨在支持 RecyclerView 的对齐方式,也就是通过计算对齐 RecyclerView 中 TargetView 的指定点或者容器中的任何像素点。 ,可能有点不好理解,看了后文的效果和原理分析就好理解了。看一下文档介绍:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7dee6ec7505faad13f3ea03c4638dfd9.jpg"></p>    <p>SnapHelper 继承自 RecyclerView.OnFlingListener ,并实现了它的抽象方法 onFling , 支持 SnapHelper 的 RecyclerView.LayoutManager 必须实现 RecyclerView.SmoothScroller.ScrollVectorProvider 接口,或者你自己实现 onFling(int,int) 方法手动处理。SnapHeper 有以下几个重要方法:</p>    <ul>     <li> <p>attachToRecyclerView:将 SnapHelper attach 到指定的 RecyclerView 上。</p> </li>     <li> <p>calculateDistanceToFinalSnap:复写这个方法计算对齐到 TargetView 或容器指定点的距离,这是一个抽象方法,由子类自己实现,返回的是一个长度为 2 的 int 数组 out,out[0] 是 x 方向对齐要移动的距离,out[1] 是 y 方向对齐要移动的距离。</p> </li>     <li> <p>calculateScrollDistance:根据每个方向给定的速度估算滑动的距离,用于 Fling 操作。</p> </li>     <li> <p>findSnapView:提供一个指定的目标 View 来对齐,抽象方法,需要子类实现</p> </li>     <li> <p>findTargetSnapPosition:提供一个用于对齐的 Adapter 目标 position,抽象方法,需要子类自己实现。</p> </li>     <li> <p>onFling:根据给定的 x 和 y 轴上的速度处理 Fling。</p> </li>    </ul>    <h2>3 LinearSnapHelper & PagerSnapHelper</h2>    <p>上面讲了 SnapHelper 的几个重要的方法和作用,SnapHelper 是一个抽象类,要使用SnapHelper,需要实现它的几个方法。而 Google 内置了两个默认实现类, LinearSnapHelper 和 PagerSnapHelper  ,LinearSnapHelper 可以使 RecyclerView 的当前 Item 居中显示(横向和竖向都支持),PagerSnapHelper  看名字可能就能猜到,使RecyclerView 像ViewPager 一样的效果,每次只能滑动一页(LinearSnapHelper 支持快速滑动), PagerSnapHelper 也是 Item 居中对齐。接下来看一下使用方法和效果。</p>    <p>(1) LinearSnapHelper</p>    <p>LinearSnapHelper  使当前 Item 居中显示,常用场景是横向的 RecyclerView, 类似ViewPager 效果,但是又可以快速滑动(滑动多页)。代码如下:</p>    <pre>  <code class="language-java">LinearLayoutManager manager = new LinearLayoutManager(getContext());   manager.setOrientation(LinearLayoutManager.VERTICAL);   mRecyclerView.setLayoutManager(manager);// 将SnapHelper attach 到RecyclrView   LinearSnapHelper snapHelper = new LinearSnapHelper();   snapHelper.attachToRecyclerView(mRecyclerView);</code></pre>    <p>代码很简单,new 一个 SnapHelper 对象,然后 Attach 到 RecyclerView 即可。</p>    <p>效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/3f06eac2eb8fb3c7ca38ebc5acae210d.gif"></p>    <p>如上图所示,简单几行代码就可以用 RecyclerView 实现一个类似 ViewPager 的效果,并且效果更赞。可以快速滑动多页,当前页剧中显示,并且显示前一页和后一页的部分。如果使用 ViewPager 来做还是有点麻烦的。除了上面的效果外,如果你想要和 ViewPager 一样,限制一次只让它滑动一页,那么你就可以使用 PagerSnapHelper 了,接下来看一下PagerSnapHelper 的使用效果。</p>    <p>(2) PagerSnapHelper (在Android 25.1.0 support 包加入的)<br> PagerSnapHelper 的展示效果和 LineSnapHelper 是一样的,只是 PagerSnapHelper 限制一次只能滑动一页,不能快速滑动。代码如下:</p>    <pre>  <code class="language-java">PagerSnapHelper snapHelper = new PagerSnapHelper();    snapHelper.attachToRecyclerView(mRecyclerView);</code></pre>    <p>PagerSnapHelper效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/238bd0823949dee27452913fa2595f91.gif"></p>    <p>上面展示的是 PagerSnapHelper 水平方向的效果,竖直方向的效果和 LineSnapHelper 竖直的方向的效果差不多,只是不能快速滑动,就不在介绍了,感兴趣的可以把它们的效果都试一下。</p>    <p>上面就是 LineSnapHelper 和  PagerSnapHelper 的使用和效果展示,了解了它的使用方法和效果,接下来我们看一下它的实现原理。</p>    <h2>4 SnapHelper原码分析</h2>    <p>上面介绍了 SnapHelper 的使用,那么接下来我们来看一下 SnapHelper 到底是怎么实现的,走读一下源码:</p>    <p>(1) 入口方法,attachToRecyclerView</p>    <p>通过</p>    <p>attachToRecyclerView 方法将 SnapHelper attach 到 RecyclerView,看一下这个方法做了哪些事情:</p>    <pre>  <code class="language-java">/**      *      * 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();          }      }</code></pre>    <p>(2) snapToTargetExistingView :</p>    <p>这个方法用于第一次Attach到 RecyclerView 时对齐 TargetView,或者当 Scroll 被触发的时候和 fling 操作的时候对齐 TargetView 。在 attachToRecyclerView 和 onScrollStateChanged 中都调用了这个方法。</p>    <pre>  <code class="language-java">/**      *      * 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]);          }      }</code></pre>    <p>(3) Filing 操作时对齐:</p>    <p>SnapHelper 继承了 RecyclerView.OnFlingListener,实现了 onFling 方法。</p>    <pre>  <code class="language-java">/**  * 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;      }</code></pre>    <p>其实通过上面的 3 个方法就实现了 SnapHelper 的对齐,只是有几个抽象方法是没有实现的,具体的对齐规则交给子类去实现。</p>    <p>接下来看一下 LinearSnapHelper 是怎么实现剧中对齐的: 主要是实现了上面提到的三个抽象方法, findTargetSnapPosition 、 calculateDistanceToFinalSnap 和 findSnapView 。</p>    <p>(1) calculateDistanceToFinalSnap :计算最终对齐要移动的距离,返回一个长度为 2 的int 数组 out,out[0] 为 x 方向移动的距离,out[1] 为 y 方向移动的距离。</p>    <pre>  <code class="language-java">@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;      }</code></pre>    <p>(2) findSnapView:   找到要对齐的 View</p>    <pre>  <code class="language-java">// 找到要对齐的目标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;      }</code></pre>    <p>(3) findTargetSnapPosition :   找到需要对齐的目标 View 的的 Position</p>    <pre>  <code class="language-java">@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 加上上面算出来的差值。//后面代码省略...}</code></pre>    <p>以上就分析了 LinearSnapHelper 实现滑动的时候居中对齐和 fling 时居中对齐的源码。整个流程还是比较简单清晰的,就是涉及到比较多的位置计算比较麻烦。熟悉了它的实现原理,从上面我们知道,SnapHelper 里面实现了对齐的流程,但是怎么对齐的规则就交给子类去处理了,比如 LinearSnapHelper 实现了居中对齐,PagerSnapHelper 实现了居中对齐,并且限制只能一次滑动一页。那么我们也可以继承它来实现我们自己的 SnapHelper,接下来看一下自己实现一个 SnapHelper。</p>    <h2>5 自定义 SnapHelper</h2>    <p>上面分析了 SnapHelper 的流程,那么这节我们来自定义一个 SnapHelper , LinearSnapHelper 实现了居中对齐,那么我们来试着实现 Target View 开始对齐。  当然了,我们不用去继承 SnapHelper,既然 LinearSnapHelper 实现了居中对齐,那么我们只要更改一下对齐的规则就行,更改为开始对齐(计算目标 View到 Parent start 要滑动的距离),其他的逻辑和 LinearSnapHelper 是一样的。因此我们选择继承 LinearSnapHelper ,具体代码如下:</p>    <pre>  <code class="language-java">/**  * 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;        }    }</code></pre>    <p>使用的时候,更改为使用 StartSnapHelper,代码如下:</p>    <pre>  <code class="language-java">StartSnapHelper snapHelper = new StartSnapHelper();    snapHelper.attachToRecyclerView(mRecyclerView);</code></pre>    <p>效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/15af4b7b2e5d8a5875ba6f9f99ab7443.gif"></p>    <p>以上就实现了一个 Start 对齐的效果,此外,在 Github 上发现一个实现了好几种 Snap 效果的库,比如,start 对齐、end 对齐,top 对齐等等。有兴趣的可以去弄来玩一下,地址:[Snap 效果库]。( https://github.com/rubensousa/RecyclerViewSnap )</p>    <h2>6 总结</h2>    <p>SnapHelper 是对 RecyclerView 的一个扩展,可以很方便的实现类似 ViewPager 的效果,比ViewPager 效果更好,当我们要实现卡片式的浏览或者图库照片浏览时,使用RecyclerView + SnapHelper 的效果要比 ViewPager 的效果好很多。因此掌握 SnapHelper 的使用技巧,能帮助我们方便的实现一些滑动交互效果,以上就是对 Snapuhelper 的总结,如有问题,欢迎留言交流。本文 Demo 已上传 Github AndroidTrainingSimples https://github.com/pinguo-zhouwei/AndroidTrainingSimples</p>    <h3>参考:</h3>    <p>Using SnapHelper in RecyclerView</p>    <p> </p>    <p> </p>    <p> </p>    <p>来自:http://mp.weixin.qq.com/s/DMksgHOuGWhztESGfDgucA</p>    <p> </p>