RecyclerView的辅助类----SnapHelper使用及源码浅析 下

上一篇SnapHelper使用及属性浅析 上主要记载了Snap的一些基础使用,基本工作流程,及滑动速度相关的计算。这一篇旨在补上扩展应用,及扩展时需要注意的一些事项。

LinearSnapHelper

也不多说,官方已经提供了两个扩展类LinearSnapHelperPagerSnapHelper,这里拿一个出来分析分析,就知道怎么用了。
先看下大概有哪些方法(也可以看上一篇的类图):
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

先做好心理准备,从上图就可以发现,局面被LayoutManagerOrientationHelper把持着~~
依上一篇总结出的工作流程来依次看:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

emmmm…好吧,这是忽略attachToRecyclerView的情况,还是先看一下attach吧,这里copy一下上一篇的内容:
attachToRecyclerView方法主要做了三件事:

  1. 通过setupCallbacks添加了OnFlingListener与OnScrollListener
  2. 创建了专属的Scroller
  3. 调用了snapToTargetExistingView

snapToTargetExistingView方法中依次调用了findSnapViewcalculateDistanceToFinalSnap

1.findSnapView

没啥事先看看findSnapView吧:

//LinearSnapHepler.java
    @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;
    }

    @NonNull
    private OrientationHelper getHorizontalHelper(
            @NonNull RecyclerView.LayoutManager layoutManager) {
        if (mHorizontalHelper == null || mHorizontalHelper.mLayoutManager != layoutManager) {
            mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
        }
        return mHorizontalHelper;
    }
    
    @NonNull
    private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
        if (mVerticalHelper == null || mVerticalHelper.mLayoutManager != layoutManager) {
            mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
        }
        return mVerticalHelper;
    }

这里根据列表的滚动方向选择了相应的OrientationHelper ,然后进入了findCenterView,方法名说得很清楚,找到中间那个View,所以怎样才算“中间”?继续看:

//LinearSnapHepler.java
    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;
    }

看到第一行layoutManager.getChildCount(),就被惊到了!

        int childCount = layoutManager.getChildCount();

难道要获取遍历所有item,通过位置计算比较得到离“中心”最近的那个View??看起来很像这么回事啊!
答案是否定的。

//LayoutManager.java
		ChildHelper mChildHelper;

        public int getChildCount() {
            return mChildHelper != null ? mChildHelper.getChildCount() : 0;
        }
//ChildHelper.java
    /** * Returns the number of children that are not hidden. * * @return Number of children that are not hidden. * @see #getChildAt(int) */
    int getChildCount() {
        return mCallback.getChildCount() - mHiddenViews.size();
    }

通过看源码,别的细节暂时不用顾及,只需要知道这里的getChildCount是指已经被展示出来的Item的数目。也就是说,只有一屏的Item数量

确定了要列入比较的Item数目,接下来自然是开始比较了,怎么比?以什么为标准比?

		final int center;
        if (layoutManager.getClipToPadding()) {
            center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
        } else {
            center = helper.getEnd() / 2;
        }

这里的center就是比较标准,就是所谓的【参考位置】,从上面这段代码大概可以看出是在计算中心点,那么问题就来了:

  1. layoutManager.getClipToPadding()是什么意思?为什么要判断这个?
  2. helper.getStartAfterPadding() + helper.getTotalSpace() / 2计算出来的结果与helper.getEnd() / 2计算出来的结果有又什么不同?
1.1 getClipToPadding

先看这个getClipToPadding,通俗地说,这个属性可以控制RecyclerView在展示Item的时候,需不需要考虑RecyclerView本身padding对Item的影响,这里的影响是指剪切或覆盖的意思,这个属性在xml中可以设置,默认情况下是为true的,也就是通过情况下,会对Item的展示造成影响,比如这里的RecyclerView是这样的:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical">

    <android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" android:clipToPadding="true" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">

    </android.support.v7.widget.RecyclerView>
</LinearLayout>

item.xml是这样的:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="300px" android:layout_height="200px" android:padding="15px" android:layout_margin="5px" android:background="#888888" android:orientation="vertical">

    <TextView android:id="@+id/tv_demo" android:layout_gravity="center" android:textSize="18sp" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>

这里为了看出效果,RecyclerView设定的paddingStart和paddingEnd设置的值不同,并把背景色设置成了比较显眼的红色,另外所有的单位都是直接用的px,方便后面看值进行计算。然后代码中使用普通的LinearSnapHelper,接着就看效果,
当android:clipToPadding=”true”的时候是这样的:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

很明显,RecyclerView本身的padding在阻挡Item的,也就是padding对Item进行残忍地剪切,通通都剪掉!!

当android:clipToPadding=”false”的时候是这样的:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

这样就和谐多了。
通常开发中,也是将android:clipToPadding置为false的时候居多,毕竟要美观许多。

至于为什么会产生这种效果,怎么实现的,猜都猜得出来,肯定和Canvas的translate关系甚大,顺手看下源码:

//RecyclerView.java
   @Override
    public void draw(Canvas c) {
        super.draw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDrawOver(c, this, mState);
        }
        // TODO If padding is not 0 and clipChildrenToPadding is false, to draw glows properly, we
        // need find children closest to edges. Not sure if it is worth the effort.
        boolean needsInvalidate = false;
        if (mLeftGlow != null && !mLeftGlow.isFinished()) {
            final int restore = c.save();
            final int padding = mClipToPadding ? getPaddingBottom() : 0;
            c.rotate(270);
            c.translate(-getHeight() + padding, 0);
            needsInvalidate = mLeftGlow != null && mLeftGlow.draw(c);
            c.restoreToCount(restore);
        }
        if (mTopGlow != null && !mTopGlow.isFinished()) {
            final int restore = c.save();
            if (mClipToPadding) {
                c.translate(getPaddingLeft(), getPaddingTop());
            }
            needsInvalidate |= mTopGlow != null && mTopGlow.draw(c);
            c.restoreToCount(restore);
        }
        if (mRightGlow != null && !mRightGlow.isFinished()) {
            final int restore = c.save();
            final int width = getWidth();
            final int padding = mClipToPadding ? getPaddingTop() : 0;
            c.rotate(90);
            c.translate(-padding, -width);
            needsInvalidate |= mRightGlow != null && mRightGlow.draw(c);
            c.restoreToCount(restore);
        }
        if (mBottomGlow != null && !mBottomGlow.isFinished()) {
            final int restore = c.save();
            c.rotate(180);
            if (mClipToPadding) {
                c.translate(-getWidth() + getPaddingRight(), -getHeight() + getPaddingBottom());
            } else {
                c.translate(-getWidth(), -getHeight());
            }
            needsInvalidate |= mBottomGlow != null && mBottomGlow.draw(c);
            c.restoreToCount(restore);
        }

        // If some views are animating, ItemDecorators are likely to move/change with them.
        // Invalidate RecyclerView to re-draw decorators. This is still efficient because children's
        // display lists are not invalidated.
        if (!needsInvalidate && mItemAnimator != null && mItemDecorations.size() > 0
                && mItemAnimator.isRunning()) {
            needsInvalidate = true;
        }

        if (needsInvalidate) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

细节不论,关于mClipToPadding的想法是没有错的,果然是在translate
至于这段代码中的mTopGlow、mLeftGlow之类的,好像方向与变量的命名有点迷?其实关系不大,这几个变量都是EdgeEffect对象,顾名思义,是对边缘效果进行定制的,改天看如果有时间去详细了解一下。

1.2 OrientationHelper

现在回到正题。
layoutManager.getClipToPadding确确实实是会对center的判断产生影响的,在clip=true的情况也就是考虑到padding对Item的剪切影响下

            center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;

这里就得研究一下根据helper算出来的各种数值了,这里的helper是OrientationHelper,假如是横向滚动,那么我们拿到是一个这样的helper:

//LinearSnapHelper.java
    public static OrientationHelper createHorizontalHelper(
            RecyclerView.LayoutManager layoutManager) {
        return new OrientationHelper(layoutManager) {
            @Override
            public int getEndAfterPadding() {
                return mLayoutManager.getWidth() - mLayoutManager.getPaddingRight();
            }

            @Override
            public int getEnd() {
                return mLayoutManager.getWidth();
            }

            @Override
            public void offsetChildren(int amount) {
                mLayoutManager.offsetChildrenHorizontal(amount);
            }

            @Override
            public int getStartAfterPadding() {
                return mLayoutManager.getPaddingLeft();
            }

            @Override
            public int getDecoratedMeasurement(View view) {
                final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                        view.getLayoutParams();
                return mLayoutManager.getDecoratedMeasuredWidth(view) + params.leftMargin
                        + params.rightMargin;
            }

            @Override
            public int getDecoratedMeasurementInOther(View view) {
                final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                        view.getLayoutParams();
                return mLayoutManager.getDecoratedMeasuredHeight(view) + params.topMargin
                        + params.bottomMargin;
            }

            @Override
            public int getDecoratedEnd(View view) {
                final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                        view.getLayoutParams();
                return mLayoutManager.getDecoratedRight(view) + params.rightMargin;
            }

            @Override
            public int getDecoratedStart(View view) {
                final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
                        view.getLayoutParams();
                return mLayoutManager.getDecoratedLeft(view) - params.leftMargin;
            }

            @Override
            public int getTransformedEndWithDecoration(View view) {
                mLayoutManager.getTransformedBoundingBox(view, true, mTmpRect);
                return mTmpRect.right;
            }

            @Override
            public int getTransformedStartWithDecoration(View view) {
                mLayoutManager.getTransformedBoundingBox(view, true, mTmpRect);
                return mTmpRect.left;
            }

            @Override
            public int getTotalSpace() {
                return mLayoutManager.getWidth() - mLayoutManager.getPaddingLeft()
                        - mLayoutManager.getPaddingRight();
            }

            @Override
            public void offsetChild(View view, int offset) {
                view.offsetLeftAndRight(offset);
            }

            @Override
            public int getEndPadding() {
                return mLayoutManager.getPaddingRight();
            }

            @Override
            public int getMode() {
                return mLayoutManager.getWidthMode();
            }

            @Override
            public int getModeInOther() {
                return mLayoutManager.getHeightMode();
            }
        };
    }

有点多,这里把后面要用到几个的方法集中说一下,为了更好懂,这里把真实情况的数据引入,因为当前是横向列表,所以竖直方向的一些属性可以先不考虑。
那么按照之前的xml看,

    <android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" android:clipToPadding="true" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">
    </android.support.v7.widget.RecyclerView>

结合真机的数据—屏幕像素宽度为1080px,高度为1920px,可以得出一些数据:

属性数值(px)
屏幕width1080
RecyclerView.PaddingLeft60
RecyclerView.PaddingRight120
RecyclerView.Margin10
RecyclerView.Width屏幕Width- margin*2 = 1060
getTotalSpaceRV.Width – paddingLeft – paddingRight = 1060 – 60 – 120 = 880
getEndRV.Width = 1060
getEndAfterPaddingRV.Width – paddingRight = 1060 -120 = 940
getStartAfterPaddingpaddingLeft = 60
getEndPaddingpaddingRight = 120

这些可以不用传入View就得到的值,是只与RecyclerView相关的值,只要RecyclerView本身属性不变,上面这些值都是不会变的。

还有几个计算Item的值,需要结合item.xml来进行计算:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="300px" android:layout_height="200px" android:padding="15px" android:layout_margin="5px" android:background="#888888" android:orientation="vertical">

    <TextView android:id="@+id/tv_demo" android:layout_gravity="center" android:textSize="18sp" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
属性数值(px)
Item.Width300
Item.Padding15
Item.Margin5
Item Decorations.Width3
getDecoratedMeasurementItem.Width + Item.Margin X 2 + Item.Decorations.Width X N = 313

这里其实可以不用列出padding,padding已经算在Item.Width中了,不会再额外计算。
最后一个属性getDecoratedMeasurement,很重要,可以看到源码中有一系列getDecoratedxx的计算方法,其实指的就是额外计算Decoration。上面表中getDecoratedMeasurement只计算一个Decoration的情况。

//RecyclerView.java
    public abstract static class ItemDecoration {
		//各种代码
	}

RecyclerView提供了ItemDecoration以供开发者使用,顾名思义,是为了美化Item,为Item搞装修(…我也不知道自己在说什么),这个Decoration的层级尚在margin之外,也就是这样的:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

ItemDecoration最常用的可能就是分割线了,因为不像ListView一样本身有一个divider属性,RecyclerView的分割线需要继承ItemDecoration类自行实现,然后通过方法添加进去,就像这样:

		rvDemo.addItemDecoration(new QuickItemDecoration(this, QuickItemDecoration.HORIZONTAL_LIST));

当然ItemDecoration的应用远远不止于此,就像搞装修不可能只能铺个地板一样…

话说回来,所以这里所有关于View的计算都是把Decoration的大小计算在内的
另外值得一提的是,在常规作为分割线的应用中,一个Item只对应一个Decoration,而不是像padding和margin那样可以一次设置就可以管4个方向,Decoration想要实现这样的,可能就要在ItemDecoration中多费些功夫了。
这也是上面的表中,最终算得300+5*2+3=313的原因。

那么,最后再看这两个计算方法:

属性数值(px)
getDecoratedStartItem.left – Item.Decoration – leftMargin = ?
getDecoratedEndItem.right + Item.Decoration + rightMargin = ?

必须要清楚,这里的两个属性,是在layout过程中才能确定的,得出的是layout相关的top/left/bottom/right属性,而不是像上面那些类似width/height的大小值;
简单的说,之前那些计算的那些值基本都是正数,而这里的值是可以为负数的。

这里根据图提供一些信息:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

显示为“1”的Item的对应属性为:

属性数值(px)
getEnd1060
getTotalSpace880
getDecoratedStart31
getDecoratedEnd344
getDecoratedMeasurement313
getEndAfterPadding940
getStartAfterPadding60

看到这里,心里大概有一个量化的认识了,现在再回头看代码中的逻辑应该就轻松一些了,重新祭出findCenterView:

//LinearSnapHepler.java
    private View findCenterView(RecyclerView.LayoutManager layoutManager,
            OrientationHelper helper) {
        int childCount = layoutManager.getChildCount();//childCount=3
        if (childCount == 0) {
            return null;
        }

        View closestChild = null;
        final int center;
        if (layoutManager.getClipToPadding()) {
            center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;//center=60+880/2=500
        } else {
            center = helper.getEnd() / 2;//center=1060/2=530
        }
        int absClosest = Integer.MAX_VALUE;
        
		//childCount=3
        for (int i = 0; i < childCount; i++) {
            final View child = layoutManager.getChildAt(i);
            //第1个childCenter=31+313/2=187
            int childCenter = helper.getDecoratedStart(child)
                    + (helper.getDecoratedMeasurement(child) / 2);
            //计算各个child中心位置与参考中心位置的绝对差值
            int absDistance = Math.abs(childCenter - center);

            //比较得出最近那一个
            if (absDistance < absClosest) {
                absClosest = absDistance;
                closestChild = child;
            }
        }
        return closestChild;
    }

这段代码就本身逻辑而言是很简单的,主要做了三件事:

  1. 确定“参考位置”,这里是设定为RecyclerView的中心,当然要考虑clip的情况(外部参考位置)
  2. 计算每个已显示View的本身中心位置(内部参考位置)
  3. 计算得出最接近“参考位置”的那一个View(内部参考位置最贴近外部参考位置的View)

到这里,findSnapView的逻辑就结束了。

2.calculateDistanceToFinalSnap

不多说,放源码过来吧:

//LinearSnapHelper.java
    @Override
    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;
    }

我们已经知道out[]是一个大小为2的数组,分别存储了x轴与y轴的距离数值。
接下来看在distanceToCenter中是如何计算的:

//LinearSnapHelper.java
    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;
    }

没有什么意外,这里和之前findCenterView有一点相似,还是把“参考位置”的坐标计算出来,然后得出targetView与“参考位置”距离。

转念一想,有没有什么方法可以复用findCenterView算出的“参考位置”坐标啊?也省得这里又写一次?本来这个参考位置在相同的RecyclerView中,也应该一直是固定的,不会有变动。
再一想,岂止省了一次…
将来每次滚动都要调用一次,每次都要重新计算…

所以官方给的也并非是完美的,比如这里,还是存在优化空间的,这也给了广大开发者一条活路…要是官方把什么工具类辅助类都写完了,什么都封装好了,其他开发者将会失去很大的乐趣…

等等,似乎有什么事情不对…
这里!居然可以把值算出来!
现在是处于attachRecyclerView的逻辑中,那么下一步根据snapToTargetExistingView的逻辑,那岂不是马上就要开始滚动了?
哈?
smoothScrollBy?

    void snapToTargetExistingView() {
		//略过
        int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
        if (snapDistance[0] != 0 || snapDistance[1] != 0) {
            mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
        }
    }

《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

经过调试,发现问题还是出现在findSnapView的过程中,

//LinearSnapHelper.java
    private View findCenterView(RecyclerView.LayoutManager layoutManager,
            OrientationHelper helper) {
        int childCount = layoutManager.getChildCount();
        if (childCount == 0) {
            return null;
        }
        //略过
    }

这时的layoutManager.getChildCount得出的值为0,

        public int getChildCount() {
            return mChildHelper != null ? mChildHelper.getChildCount() : 0;
        }

调试也看到,并不是mChildHelper为空,而是mChildHelper此时的childCount也为0,是它没有加载Item…
至于为什么layoutManager还没有加载Item,应该和RecyclerView的初始化与加载Item的流程有关了,留待后来吧。

所以attachRecyclerView虽然会调用findSnapView,但却在findSnapView的中途就中断了,并没有运行之后的逻辑。

不管怎么样,findSnapViewcalculateDistanceToFinalSnap的逻辑与细节都已经理清楚了,一个在找符合标准的targetView,一个在算targetView离参考位置还有多远。

接下来看看第三个方法吧。

3.findTargetSnapPosition

按照上面的结论,其实在三个抽象方法中,最先调用并起到作用的应该是findTargetSnapPosition,因为attachRecyclerView并没有真正运行其他两个方法的逻辑,那么一切的起始还是像上面那张时序图一样,从onFling开始。
还是来看代码吧:

//LinearSnapHelper.java
   @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
            int velocityY) {
        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
            return RecyclerView.NO_POSITION;
        }

        final int itemCount = layoutManager.getItemCount();
        if (itemCount == 0) {
            return RecyclerView.NO_POSITION;
        }
		//这里手动调用了一次findSnapView
        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;
        // 一个向量标记,只是为了标明方向
        PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
        if (vectorForEnd == null) {
            // cannot get a vector for the given position.
            return RecyclerView.NO_POSITION;
        }

		//计算需要跳跃的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;
        }

		//根据当前的pos加上需要jump的pos数量差值,得出将要滑动到的pos
        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;
    }

先概述一下这里的逻辑:

  1. 指定currentPosition,这个值理论上可以任意指定
  2. 计算出在当前速度下,需要滑动的Item数量deltaJump,为正数表明正向滑动,为负数表明反向滑动,这个值理论上也是可以任意指定的
  3. 根据前两步得到的数值,得出最后预计到达的位置targetPosition

可以看到,LinearSnapHelper这里手动调用了一次findSnapView,也就是说这里会得到与”参考位置“距离最近的那个View。
但是千万别认为就是根据当前静止状态下的数据计算出的结果。
请一定记住,findTargetSnapPosition方法是在snapFromFling中调用的,而snapFromFling的执行是有条件的,回顾一下:

    @Override
    public boolean onFling(int velocityX, int velocityY) {
    	//略过
        int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
        return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
                && snapFromFling(layoutManager, velocityX, velocityY);
    }

用户滑动屏幕的速度必须大于一个下限值minFlingVelocity才能如愿进入此方法中,默认是50dp,在DPI为480的手机上换算出来就是1秒必须滑动150像素的速度才能触发

这意味着,通过findSnapView得到的View不一定是用户手指开始滑动时最接近“参考位置”的Item
在速度不够快时,要么不能进入snapFromFling,要么有可能等用户手指加速到下限值以后,得到的View已经变为本来最接近”参考位置“所在的Item的下一个,或者下下一个,理论上都有可能;
而在速度够快,甚至快“过头”了的时候,快到findSnapView调用时,原来那个最接近“参考位置”的Item已经被滑动出“选择范围”内的时候,得到Item依旧有可能是下一个…

而在LinearSnapHelper中,手动调用findSnapView得到的View是具*“起始”意义的,后面根据一定的算法,算出要自动滑动过多少个Item,然后与findSnapView得到的View的pos相加,才能确定最终的targetPosition,那么在findSnapView不能确定的情况下,targetPosition始终是不确定的

好,接着findSnapView的逻辑往下,通过layoutManager得到snapView所在的位置currentPosition,然后,有这么一行代码:

PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);

这是在干什么?
虽然不起眼,但其实影响相当大,只是一般情况下,这个值都是固定为1,这个值是在具体实现的LayoutManager中实现的,本文到这里也一直用的是LinearLayoutManager,所以直接看:

//LinearLayoutManager.java
    @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        if (getChildCount() == 0) {
            return null;
        }
        final int firstChildPos = getPosition(getChildAt(0));
        final int direction = targetPosition < firstChildPos != mShouldReverseLayout ? -1 : 1;
        if (mOrientation == HORIZONTAL) {
            return new PointF(direction, 0);
        } else {
            return new PointF(0, direction);
        }
    }

mShouldReverseLayout没有意外是可以在xml中设置的,代表着是否反转,默认为false,即不反转,

    <android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" app:reverseLayout="false" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">
    </android.support.v7.widget.RecyclerView>

那么可以看到这个值只会返回-1或1,1代表着正向,-1代表着反向,在绝大多数情况下,传入的targetPosition都为正值,那么返回的大多数也是1。
而后的逻辑也体现其作用了,如果为正数,那么一切好说,deltaJump不变,即朝原有的方向滑动;如果为负数,就代表这个滑动必须朝原有的方向反向操作。是以deltaJump的方向为基础,并不是绝对值。

在方向确定的情况下,接下来就是通过estimateNextPositionDiffForFling估算以当前的速度,会滑动过多少个Item。这个算法和View所在位置坐标有关,和当前速度有关,也和很久很久以前在attachRecyclerView初始化的Scroller有关:

//SnapHelper.java
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
            throws IllegalStateException {
		//略过
        if (mRecyclerView != null) {
            setupCallbacks();
            mGravityScroller = new Scroller(mRecyclerView.getContext(),
                    new DecelerateInterpolator());
            snapToTargetExistingView();
        }
    }

所以在SnapHelper就声明了是一个DecelerateInterpolator的Scroller,是会慢慢减速的,如果设定成匀速或加速…
就不用估算了,最后铁定滑到最后一个Item。美滋滋。

好了,至此,所有方法已经就位,就等反复调用了。

调用情况

真实的调用情况,就如一开始那张时序图一样,先通过OnFling的监听调用snapFromFling,而在snapFromFling中,其实就已经启动了自动滑动的过程,

//SnapHelper.java
    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;
    }

而layoutManager.startSmoothScroll(smoothScroller)则是启动自行滑动的关键方法,它会让smoothScroller开始工作,并回调onTargetFound方法通过calculateDistanceToFinalSnap得到需要滑动的值,并算出需要滑动的时间,接着通过action.update更新数值,然后开始滑动,具体流程可看上一篇的时序图。
这里放出createSnapScroller的源码:

   protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
        if (!(layoutManager instanceof ScrollVectorProvider)) {
            return null;
        }
        return new LinearSmoothScroller(mRecyclerView.getContext()) {
            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
                        targetView);
                final int dx = snapDistances[0];
                final int dy = snapDistances[1];
                final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    action.update(dx, dy, time, mDecelerateInterpolator);
                }
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
            }
        };
    }

这也意味着触发OnFling监听后,调用了snapFromFling方法,就已经可以让RecyclerView滑动到相应的位置了。
那么在onScrollStateChanged中监听停止滚动,再调用snapToTargetExistingView时,会发现findSnapView所得到targetView,已经整整齐齐对准了“参考位置”了,那么第二次调用calculateDistanceToFinalSnap所计算出的结果必将为0,于是不再滚动。

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

                //略过
            };

   void snapToTargetExistingView() {
        //略过
        View snapView = findSnapView(layoutManager);
        if (snapView == null) {
            return;
        }
        int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
        //此时计算出的距离已经是0了
        if (snapDistance[0] != 0 || snapDistance[1] != 0) {
            mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
        }
    }

滑动停止时,虽然通过findSnapView仍然可以拿到targetView,但此时的targetView已经滚动到相应的参考位置了,所以再通过calculateDistanceToFinalSnap计算,得出结果只能是0了。

一句话,如果findTargetSnapPosition/findSnapView/calculateDistanceToFinalSnap三个方法合作默契的话,是不需要OnScrollListener的。
至少在LinearSnapHelper中是比较默契的。

关于LinearSnapHelper的东西,就记载到这里了。

扩展应用

说是扩展,其实也没有几点可以扩展,目前想到比较容易实现的扩展点:

  • 自定“参考位置”
  • 自定滑动速度(时间)
  • 自定一次Fling可滑动的最大Item数目
自定“参考位置”

如LinearSnapHelper中的findCenterView一样,可以自定findxxxView,设定一个位置坐标作为参考位置,比如左对齐,findSnapView就要这么写:

	private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
		int childCount = layoutManager.getChildCount();
		if (childCount == 0) {
			return null;
		}

		View closestChild = null;

		final int start;
		if (layoutManager.getClipToPadding()) {
			start = helper.getStartAfterPadding();
		} else {
			start = 0;
		}

		int absClosest = Integer.MAX_VALUE;

		for (int i = 0; i < childCount; i++) {
			final View child = layoutManager.getChildAt(i);
			int childStart = helper.getDecoratedStart(child);
			int absDistance = Math.abs(childStart - start);

			if (absDistance < absClosest) {
				absClosest = absDistance;
				closestChild = child;
			}
		}
		return closestChild;
	}

calculateDistanceToFinalSnap相应地就要计算:

	private int distanceToStart(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
		if (layoutManager.getClipToPadding()) {
			return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
		} else {
			return helper.getDecoratedStart(targetView);
		}
	}

只需要改动View本身的参考位置,与RecyclerView的参考位置就行了。
比如右对齐:

	private View findEndView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
		//略过,同上
		final int end;
		if (layoutManager.getClipToPadding()) {
			end = helper.getEndAfterPadding();
		} else {
			end = helper.getEnd();
		}	
		int absClosest = Integer.MAX_VALUE;	
		for (int i = 0; i < childCount; i++) {
			final View child = layoutManager.getChildAt(i);
			int childEnd = helper.getDecoratedEnd(child);
			int absDistance = Math.abs(childEnd - end);
			//略过,同上
		}
		return closestChild;
	}

相应地距离计算则是:

	private int distanceToEnd(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
		if (layoutManager.getClipToPadding()) {
			return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding();
		} else {
			return helper.getDecoratedEnd(targetView) - helper.getEnd();
		}
	}

和LinearSnapHelper一样,同样考虑getClipToPadding的情况。
左对齐、居中对齐、右对齐应该是三种最常见的情况,理论上是可以有无限种对齐方式的;
对齐方式(参考位置)可以分为两大类。

  • 第一种是参考位置比例相同的,比如左对齐是指targetView的左边界对齐RecyclerView的左边界,比如使targetView的1/4处对齐RecyclerView的1/4处
  • 第二种是参考位置比例不同的,比如偏要targetView的左边界对齐RecyclerView的中心

第二种不要以为行不通,代码逻辑很通顺的:

	private View findSpecialView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
		//略过,同上
		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 childStart = helper.getDecoratedStart(child);
			int absDistance = Math.abs(childStart - center);
			//略过,同上
		}
		return closestChild;
	}
	private int distanceToSpecial(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
		final int childStart = helper.getDecoratedStart(targetView);
		final int containerCenter;
		if (layoutManager.getClipToPadding()) {
			containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
		} else {
			containerCenter = helper.getEnd() / 2;
		}
		return childStart - containerCenter;
	}

运行效果是这样的:
《RecyclerView的辅助类----SnapHelper使用及源码浅析 下》

虽然看起来确实有些沙雕就是了…

但至少说明效果是完全可以实现的,所以“参考位置”是可以自由定制的。

另外就是这两个参考位置是可以复用的,所以不必像上面的代码也不必像LinearSnapHelper中那样每次都去重新计算,而是可以考虑在初始化SnapHelper的时候,就初始化这些值。只要注意这些值的计算都需要用到LayoutManager,所以一定要在LayoutManager本身初始化完成后再对这些值进行赋值就可以了。这些都是常规化操作,就不多说了。

自定滑动速度(时间)

要明白其实是并不能精确地自定速度的,因为Scroller肯定是一个DecelerateInterpolator构造而成的,是需要减速的,否则滑个不停或者来个判定急刹都不合适,所以严格来说,只能自定滑动时间。
自定滑动时间旨在覆写createSnapScroller方法,新建一个SmoothScroller:

	protected LinearSmoothScroller createSnapScroller(final RecyclerView.LayoutManager layoutManager) {
		if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
			return null;
		}
		return new LinearSmoothScroller(context) {
			@Override
			protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
				int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView);
				final int dx = snapDistances[0];
				final int dy = snapDistances[1];
				//这里的时间就是从当前滑动到targetView所用的总时间
				final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
				if (time > 0) {
					action.update(dx, dy, time, mDecelerateInterpolator);
				}
			}

			@Override
			protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
				return speedFactor / displayMetrics.densityDpi;
			}
		};
	}

onTargetFound方法中的time(单位为ms),以及calculateSpeedPerPixel的返回值(单位为每像素耗时多少ms),都是可以自行控制的。

虽然直接把time写成固定值确定有一点沙雕,但不能否认其效果…
当然写成固定值以后的效果,就是无论要滑动多长或多短的距离,时间都成固定的了,在DecelerateInterpolator的控制下,可能会产生一些奇怪的效果…就和没有冲刺的跑步一样,最后的一丁点距离需要很长的时间才能跑完这样子。

至于修改calculateSpeedPerPixel,一般也只是修改speedFactor,这个值在SnapHelper中默认为25,在LinearSnapHelper中默认为100。
当speedFactor=100时,根据上一篇文章计算的结果,在一个DPI为480的屏幕上,1080像素的距离将在减速器的作用下使用670ms滑过

具体效果可自行体验。

自定一次Fling可滑动的最大Item数目

其实就是控制findTargetSnapPosition的deltaJump:

    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
    	//略过部分代码
        int targetPos = currentPosition + deltaJump;
        if (targetPos < 0) {
            targetPos = 0;
        }
        if (targetPos >= itemCount) {
            targetPos = itemCount - 1;
        }
        return targetPos;
    }

把deltaJump定为常量,自然就定下了一次可滑动的Item数目,比如如果想把这个最大值定为一个屏幕能展示的最大Item数目,就应该:

deltaJump = orientationHelper.getTotalSpace() / orientationHelper.getDecoratedMeasurement(currentView)

这个值修改起来相对简单,不再多说。

另外,固定currentPosition也具有一定的实际意义,因为前面说到的LinearSnapHelper中使用findSnapView得到的View作为currentPosition其实是一个不确定值,就算deltaJump能确定,也只能把targetPos确定到某个范围之内,想要完全控制,固定currentPosition就变成必然的操作了。
比如把currentPosition永远定为当前屏幕展示的第一个Item,则应该:

currentPosition = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();

当然,必须在OnFling触发之前调用,否则还是会受影响,这里可以利用RecycleView可以添加多个OnScrollListener的特性为其另添一个监听器,监听在滑动开始时就把currentPosition固定下来,就像这样:

@Override
	public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
		super.attachToRecyclerView(recyclerView);
		if (recyclerView != null) {
			recyclerView.addOnScrollListener(scrollListener);
		}
	}

	public RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {

		@Override
		public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
			super.onScrollStateChanged(recyclerView, newState);
			//滚动开始时,此时界面中可见的Item是最可靠的参考标准
			if (newState == SCROLL_STATE_DRAGGING) {
				currentPosition = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();				
			}
		}

		@Override
		public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
			super.onScrolled(recyclerView, dx, dy);
		}
	};

岂不是美滋滋?

这样什么都固定下来,滑动的目标都是可以预见。
当然理论上来说,如果能知道用户滑动的手速的话,通过计算也是可以算出最终滑动的结果的,毕竟代码里就是这么算出来的。
但是人脑哪有CPU转得快…
所以想控制滑动过程还是好好写成固定值吧。

最后,这里有一只SnapHelper可以享用。基本实现了上面说的三个自定义功能,并且优化了部分计算流程,有兴趣可以看看。
https://github.com/ifmylove2011/SlimIdea/blob/master/app/src/main/java/com/xter/slimidea/presentation/widget/HorizontalLinearSnapHelper.java

以上。

    原文作者:秦汉春秋
    原文地址: https://blog.csdn.net/ifmylove2011/article/details/88777606
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞