Android SnapHelper

转载请注明出处:http://blog.csdn.net/crazy1235/article/details/53386286


SnapHelper 是 Android Support Library reversion 24.2.0 新增加的API。


SnapHelper 的应用

SnapHelper 是RecyclerView的一个辅助工具类。

它实现了RecyclerView.onFlingListener接口。而RecyclerView.onFlingListener 是一个用来响应用户手势滑动的接口。

SnapHelper是一个抽象类,官方提供了一个LinearSnapHelper子类,可以实现类似ViewPager的滚动效果,滑动结束之后让某个item停留在中间位置。

这里写图片描述

效果类似于Google Play主界面中item的滚动效果。


LinearSnapHelper的使用很简单,只需要调用 attachToRecyclerView(xxx) ,绑定上一个RecyclerView即可。

上一张自己的效果图:

LinearSnapHelper 源码分析

下面来分析一下 LinearSnapHelper

先从 attachToRecyclerView() 入手。

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

destoryCallback() 作用在于取消之前的RecyclerView的监听接口。

/**
 * Called when the instance of a {@link RecyclerView} is detached.
 */
    private void destroyCallbacks() {
        mRecyclerView.removeOnScrollListener(mScrollListener);
        mRecyclerView.setOnFlingListener(null);
    }

setupCallbacks() – 设置监听器

/**
     * Called when an instance of a {@link RecyclerView} is attached.
     */
    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已经设置了OnFlingListener,会抛出一个 状态异常 

snapToTargetExistingView()

/**
 * 找到居中显示的view,计算它的位置,调用smoothScrollBy使其居中
 */
void snapToTargetExistingView() {
        if (mRecyclerView == null) {
            return;
        }
        LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        if (layoutManager == null) {
            return;
        }
        View snapView = findSnapView(layoutManager);
        if (snapView == null) {
            return;
        }
        // 计算目标View需要移动的距离
        int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
        if (snapDistance[0] != 0 || snapDistance[1] != 0) {
            mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
        }
    }

该方法中显示调用 findSnapView() 找到目标View(需要居中显示的View),然后调用 calculateDistanceToFinalSnap() 来计算该目标View需要移动的距离。这两个方法均需要LinearSnapHelper重写。

SnapHelper.Java 中有三个抽象函数需要LinearSnapHelper 重写。

/**
 * 找到那个“snapView”
 */
public abstract View findSnapView(LayoutManager layoutManager);
/**
 * 计算targetView需要移动的距离
 * 该方法返回一个二维数组,分别表示X轴、Y轴方向上需要修正的偏移量
 */
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager,
            @NonNull View targetView);
/**
 * 根据速度找到将要滑到的position
 */
public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX,
            int velocityY);

在setupCallbacks() 方法中可以看到对RecyclerView 设置了 OnScrollListener 和 OnFlingListener 两个监听器。

查看SnapHelper可以发现

// Handles the snap on scroll case.
    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();
                    }
                }

                public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                    if (dx != 0 || dy != 0) {
                        mScrolled = true;
                    }
                }
            };

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

当滚动结束是,会调用 snapToTargetExistingView() 方法。

而当手指滑动触发onFling() 函数时,会根据X轴、Y轴方向上的速率加上 snapFromFling() 方法的返回值综合判断。

看一下 snapFromFling()

 

/**
     * Helper method to facilitate for snapping triggered by a fling.
     *
     * @param layoutManager The {@link LayoutManager} associated with the attached
     *                      {@link RecyclerView}.
     * @param velocityX     Fling velocity on the horizontal axis.
     * @param velocityY     Fling velocity on the vertical axis.
     *
     * @return true if it is handled, false otherwise.
     */
    private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
            int velocityY) {
        if (!(layoutManager instanceof ScrollVectorProvider)) {
            return false;
        }

        // 创建SmoothScroll对象
        RecyclerView.SmoothScroller smoothScroller = createSnapScroller(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;
    }

 

接下来看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;
        }

        // ...省略若干代码

        return targetPos;
    }

省略的若干代码主要是根据手势滑动的速率计算目标item的位置。具体算法不用多研究。

可以看到方法内部又调用了 findSnapView() ;

@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;
    }

这里根据LayoutManager的方向做个判断,进而调用 findCenterView() 方法。

/**
     * 返回距离父容器中间位置最近的子View
     */
    @Nullable
    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++) {  // 循环判断子View中间位值距离父容器中间位值的差值
            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; // 返回距离父容器中间位置最近的子View
    }

然后来看 calculateDistanceToFinalSnap()

 

@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;
    }

 

定义一个二维数组,根据LayoutManager的方向来判断进行赋值。

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

该方法的目的即是 计算目标View距离父容器中间位值的差值

至此,流程已经分析完毕。

总结如下:

  1. 有速率的滑动,会触发onScrollStateChanged() 和 onFling() 两个方法。

    • onScrollStateChanged() 方法内部调用 findSnapView() 找到对应的View,然后据此View在调用calculateDistanceToFinalSnap() 来计算该目标View需要移动的距离,最后通过RecyclerView.smoothScrollBy() 来移动View。

    • onFling() 方法内部调用 snapFromFling(), 然后在此方法内部首先创建了一个SmoothScroller 对象。接着调用 findTargetSnapPosition() 找到目标View的position,然后对smoothScroller设置该position,最后通过LayoutManager.startSmoothScroll() 开始移动View。

  2. 没有速率的滚动只会触发 onScrollStateChanged() 函数。

扩展

LinearSnapHelper 类的目的是将某个View停留在正中间,我们也可以通过这种方式来实现每次滑动结束之后将某个View停留在最左边或者最右边。

其实通过上面的分析,就会发现最主要的就是 calculateDistanceToFinalSnap 和 findSnapView 这两个函数。

在寻找目标View的时候,不像findCenterView那么简单。 
以为需要考虑到最后item的边界情况。判断的不好就会出现,无论怎么滑动都会出现最后一个item无法完整显示的bug。

且看我的代码:

/**
     * 注意判断最后一个item时,应通过判断距离右侧的位置
     *
     * @param layoutManager
     * @param helper
     * @return
     */
    private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
        if (!(layoutManager instanceof LinearLayoutManager)) { // only for LinearLayoutManager
            return null;
        }
        int childCount = layoutManager.getChildCount();
        if (childCount == 0) {
            return null;
        }

        View closestChild = null;
        final int start = helper.getStartAfterPadding();

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

        // 边界情况判断
        View firstVisibleChild = layoutManager.getChildAt(0);

        if (firstVisibleChild != closestChild) {
            return closestChild;
        }

        int firstChildStart = helper.getDecoratedStart(firstVisibleChild);

        int lastChildPos = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
        View lastChild = layoutManager.getChildAt(childCount - 1);
        int lastChildCenter = helper.getDecoratedStart(lastChild) + (helper.getDecoratedMeasurement(lastChild) / 2);
        boolean isEndItem = lastChildPos == layoutManager.getItemCount() - 1;
        if (isEndItem && firstChildStart < 0 && lastChildCenter < helper.getEnd()) {
            return lastChild;
        }

        return closestChild;
    }

对于“反向的”同样要考虑边界情况。

private View findEndView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
        if (!(layoutManager instanceof LinearLayoutManager)) { // only for LinearLayoutManager
            return null;
        }
        int childCount = layoutManager.getChildCount();
        if (childCount == 0) {
            return null;
        }

        if (((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition() == 0) {
            return null;
        }

        View closestChild = null;
        final int end = helper.getEndAfterPadding();

        int absClosest = Integer.MAX_VALUE;
        for (int i = 0; i < childCount; i++) {
            final View child = layoutManager.getChildAt(i);
            int childStart = helper.getDecoratedEnd(child);
            int absDistance = Math.abs(childStart - end);

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

        // 边界情况判断
        View lastVisibleChild = layoutManager.getChildAt(childCount - 1);

        if (lastVisibleChild != closestChild) {
            return closestChild;
        }

        if (layoutManager.getPosition(closestChild) == ((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition()) {
            return closestChild;
        }

        View firstChild = layoutManager.getChildAt(0);
        int firstChildStart = helper.getDecoratedStart(firstChild);

        int firstChildPos = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
        boolean isFirstItem = firstChildPos == 0;


        int firstChildCenter = helper.getDecoratedStart(firstChild) + (helper.getDecoratedMeasurement(firstChild) / 2);
        if (isFirstItem && firstChildStart < 0 && firstChildCenter > helper.getStartAfterPadding()) {
            return firstChild;
        }

        return closestChild;
    }

果图如下:

这里写图片描述

这里写图片描述


完整代码,请移步:JackSnapHelper.java

 

posted @ 2016-11-30 13:53  星辰之力  阅读(7217)  评论(0编辑  收藏  举报