Android中使用RecyclerView + SnapHel

作者: 依然范特稀西 | 来源:发表于2017-03-31 01:12 被阅读13590次

    1 . 前言

    在一些特定的场景下,如照片的浏览,卡片列表滑动浏览,我们希望当滑动停止时可以将当前的照片或者卡片停留在屏幕中央,以吸引用户的焦点。在Android 中,我们可以使用RecyclerView + Snaphelper来实现,SnapHelper旨在支持RecyclerView的对齐方式,也就是通过计算对齐RecyclerView中TargetView 的指定点或者容器中的任何像素点(包括前面说的显示在屏幕中央)。本篇文章将详细介绍SnapHelper的相关知识点。本文目录如下:

    目录.png

    2 . SnapHelper 介绍

    Google 在 Android 24.2.0 的support 包中添加了SnapHelper,SnapHelper是对RecyclerView的拓展,结合RecyclerView使用,能很方便的做出一些炫酷的效果。SnapHelper到底有什么功能呢?SnapHelper旨在支持RecyclerView的对齐方式,也就是通过计算对齐RecyclerView中TargetView 的指定点或者容器中的任何像素点。,可能有点不好理解,看了后文的效果和原理分析就好理解了。看一下文档介绍:

    SnapHealper 介绍.png

    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 内置了两个默认实现类,LinearSnapHelperPagerSnapHelper ,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 即可。

    效果如下:

    LineSnapHelper_竖直方向.gif

    上面的效果为LayoutManager的方向为VERTICAL,那么接下来看一下横向效果,很简单,和上面的区别只是更改一下LayoutManager的方向,代码如下:

    
     LinearLayoutManager manager = new LinearLayoutManager(getContext());
     manager.setOrientation(LinearLayoutManager.HORIZONTAL);
     mRecyclerView.setLayoutManager(manager);
    // 将SnapHelper attach 到RecyclrView
     LinearSnapHelper snapHelper = new LinearSnapHelper();
     snapHelper.attachToRecyclerView(mRecyclerView);
    

    效果如下:

    LineSnapHelper_水平方向.gif

    如上图所示,简单几行代码就可以用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.gif

    上面展示的是PagerSnapHelper水平方向的效果,竖直方向的效果和LineSnapHelper竖直的方向的效果差不多,只是不能快速滑动,就不在介绍了,感兴趣的可以把它们的效果都试一下。

    上面就是LineSnapHelperPagerSnapHelper的使用和效果展示,了解了它的使用方法和效果,接下来我们看一下它的实现原理。

    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 。在attachToRecyclerViewonScrollStateChanged中都调用了这个方法。

     /**
         *
         * 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 是怎么实现剧中对齐的:主要是实现了上面提到的三个抽象方法,findTargetSnapPositioncalculateDistanceToFinalSnapfindSnapView

    (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);
    

    效果如下:

    StartSnaphelper 效果.gif

    以上就实现了一个Start对齐的效果,此外,在Github上发现一个实现了好几种Snap 效果的库,比如,start对齐、end对齐,top 对齐等等。有兴趣的可以去弄来玩一下,地址:[Snap 效果库]。(https://github.com/rubensousa/RecyclerViewSnap)

    6 . 总结

    SnapHelper 是对RecyclerView 的一个扩展,可以很方便的实现类似ViewPager的效果,比ViewPager效果更好,当我们要实现卡片式的浏览或者图库照片浏览时,使用RecyclerView + SnapHelper 的效果要比ViewPager的效果好很多。因此掌握SnapHelper 的使用技巧,能帮助我们方便的实现一些滑动交互效果,以上就是对Snapuhelper的总结,如有问题,欢迎留言交流。本文Demo已上传GithubAndroidTrainingSimples

    参考:
    Using SnapHelper in RecyclerView

    相关文章

      网友评论

      • 帅气的帽子:很好 学到了
      • huangyirui:请问假如有三个item
        怎么从第一个直接滑倒第三个不会滑动一半额? 现在需求要这样。。一直调不好。求帮助
        cd7353f2bcb8:https://github.com/GcsSloop/pager-layoutmanager拿去
      • 0a5faa7ab7ab:楼主您好 我按照你这个写的 用的LinearSnapHelper 不知道为什么下一页的一部分不显示啊 你的源码我也看了 也没有找到AbsBaseFragment
      • CokeNello:楼主,PagerSnapHelper怎么监听
        ender115:@栗子酱油饼 怎么监听的啊
        CokeNello:@依然范特稀西 嗯~ o(* ̄▽ ̄*)o,我搞定了。就监听OnPagerChange
        依然范特稀西:@栗子酱油饼 监听什么事件?
      • PengFly:垂直翻页滑动。当滑动到很多页后,然后有新的刷新数据来了需要直接调用notifyDataSetChanged让recyclerview回到第0条item,此时页面还在当前位置。无法回到第0页。可以理解为抖音的点击Home刷新,然后让页面回到第0页。使用PagerSnapHelper无法回到0.
      • 63d7328b70bf:请问怎么监听recylerview滑动到的item的position啊,就像viewpager的onpageselected()方法一样
      • 寒平洛一:你好,请问一下,PagerSnapHelper怎么设置一次翻三个item,当横向滚动的时候,屏幕宽度内有三个item,每次滑动翻三个
        cd7353f2bcb8:https://github.com/GcsSloop/pager-layoutmanager
        伟大的小炮殿下:没认真听课啊,这玩意只能翻一个,继承Linear然后自己重写吧
      • loechovevega: 请问LinearSnapHelper 可以动态设置单向滑动吗,比如我想禁止recycleview横向滑动时向左滑动。我这里有个答题的需求,当前题在没答完的情况下是不能左滑去看下一道题的
        loechovevega:@依然范特稀西 还有个问题,我看到你的一个那个通用的viewpager的demo,数据填充进去后,会紊乱
        loechovevega:@依然范特稀西 嗯,我先用viewpager来实现吧
        依然范特稀西:没有给这样的API,但是因该可以通过自定义SnapHelper来实现,因为最终都是计算到目标位置的距离来固定View的,你可以好好研究一下自定义SnapHelper
      • v587的毅哥:棒!不过建议下次截图的时候在开发者选项里把触摸位置显示出来,否则别人不知道你是咋操作的
        依然范特稀西:@v587的毅哥 好建议
      • 王神仙:不错
      • a5ff9c6191f1:请问能不能发一份给我学习一下,谢谢了371718330@qq.com
        依然范特稀西:已上传Github
      • a5ff9c6191f1:代码就不能给完整吗?没弄出来
      • Persisten:新技能get

      本文标题:Android中使用RecyclerView + SnapHel

      本文链接:https://www.haomeiwen.com/subject/hsbyottx.html