轻轻松松实现RecyclerView对齐效果

在开发过程中,对齐效果是一个很常见的功能:比如我们使用ViewPager,或者是使用画廊效果的FancyCoverFlow,都无一例外的要求某一个Item居中对齐。比如看看Google Play,它实现了滑动停止后Item居中的效果,你可能会通过计算得到最接近RecyclerView中间轴位置的Item,然后计算得到偏移量,最后通过scroll滚动过去来实现。这个思路是没有问题的,但是谷歌已经帮你做好了这些事,并且让你一句话就能实现这个效果。听到这个消息,你是不是觉得有点崩溃?

Google Play

这个效果就是通过 SnapHelper来实现的。很多人都不知道 SnapHelper的存在,所以有些很常见的效果往往会花费好大力气来自己实现。我们先从实例来了解 SnapHelper如何使用,再从源码分析 SnapHelper是怎样完成对齐效果的

SnapHelper简介

在阅读代码之前,先简单介绍一下SnapHelper。我们可以在appcompat-v7包中找到SnapHelper,过老的版本里面可能会没有。SnapHelper是一个抽象类,官方提供了LinearSnapHelperPagerSnapHelper两个子类,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.OnScrollListenerRecyclerView.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没有实现ScrollVectorProvideritem的个数是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去完成相应的左右上下对齐功能。代码也不复杂,自行走读一下吧

你可能感兴趣的:(轻轻松松实现RecyclerView对齐效果)