美文网首页UI效果仿写Android开发Android技术知识
轻轻松松实现RecyclerView对齐效果

轻轻松松实现RecyclerView对齐效果

作者: 皮球二二 | 来源:发表于2018-05-22 14:32 被阅读231次

    在开发过程中,对齐效果是一个很常见的功能:比如我们使用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对齐效果

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