SwipeRefreshLayout嵌套滑动源码分析

SwipeRefreshLayout嵌套滑动源码分析

前言

SwipeRefreshLayout作为安卓官方的下拉刷新组件使用十分广泛,其中下拉手势的处理也应用了嵌套滑动机制,除了考虑到对下拉手势的支持,也能够作为NestedChild继续向父布局派发嵌套滑动事件,可以说是嵌套滑动组件编写的范例。本文仅就SwipeRefreshLayout中NestedScroll部分进行分析,没有了解过NestedScroll的同学需要补充背景知识。下面我将按照NestedScroll事件的分发顺序进行说明。

onStartNestedScroll

SwipeRefreshLayout作为NestedScrollingParent,接收子布局的事件分发。一般来说,在子布局的ACTION_DOWN事件的处理过程中,会调用到NestedScrollingChild的startNestedScroll方法,此时会由NestedScrollingChildHelper辗转调用到父布局的onStartNestedScroll方法询问父布局是否要处理嵌套滑动,如果希望处理则返回true,否则返回false,在SwipeRefreshLayout中,方法实现如下:

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        return isEnabled() && !mReturningToStart && !mRefreshing
                && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

这里有几个变量需要解释一下,mReturningToStart代表了小圆圈是否处于返回开始位置的动画过程中,这是SwipeRefreshLayout对这个字段的注释,但我在源码中找了一遍根本没有发现任何对这个字段赋值为true的代码,所以讲道理这个字段没什么用处。mRefreshing代表了是否正在刷新过程中,当小圆圈(以下简称下拉的圆形进度条为小圆圈)悬浮在顶部执行加载动画时,此变量为true。形参nestedScrollAxes指的是滑动的方向,1为横向,2为纵向。值得一说的是,在ViewCompat中定义了三种滑动方向,分别为:


//Indicates no axis of view scrolling.
public static final int SCROLL_AXIS_NONE = 0;
//Indicates scrolling along the horizontal axis.
public static final int SCROLL_AXIS_HORIZONTAL = 1 << 0;
//Indicates scrolling along the vertical axis.
public static final int SCROLL_AXIS_VERTICAL = 1 << 1;

所以如果我们想判断是否为纵向滑动,可以拿nestedScrollAxes位与SCROLL_AXIS_VERTICAL,如果不为0则为纵向滑动,同理,横向滑动的判断条件可以写为(nestedScrollAxes & ViewCompat.SCROLL_AXIS_HORIZONTAL) != 0。不知道Google的这种实现是否出于性能考量,但这个写法总是让人觉得很厉害的样子。。。

所以对于onStartNestedScroll方法,当布局处于enable状态,同时不处于刷新状态,同时子View是纵向滑动的,则此方法返回true,否则返回false。其实还是很好理解的,毕竟也没几行代码。

onNestedScrollAccepted

当onStartNestedScroll方法判定符合嵌套滑动要求(返回true)后,onNestedScrollAccepte方法将被调用,这个过程一般由NestedScrollingChildHelper完成,此时SwipeRefreshLayout的处理如下:

    @Override
    public void onNestedScrollAccepted(View child, View target, int axes) {
        // Reset the counter of how much leftover scroll needs to be consumed.
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
        // 这里作为NestedChild向父布局分发事件
        startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
        mTotalUnconsumed = 0;
        mNestedScrollInProgress = true;
    }

大家应该已经注意到了中文注释的部分,当SwipeRefreshLayout接受了嵌套滑动请求之后,又继续向父布局进行嵌套滑动回调,完成了作为NestedChild的工作。这种方式可以说是一个标准套路了,当控件既作为NestedParent又作为NestedChild的情况下,只需要在收到某个嵌套滑动事件时,把这个事件继续传递到父布局即可,这样也就避免了自己实现事件分发所带来的不确定性,只要有一个官方的NestedChild在最内层,就足以保证整个工作流的稳定性,可以说是非常安全了。包括我们熟知的NestedScrollView也是如此编写。 之后的两句赋值,这里说明一下变量的含义。mTotalUnconsumed从字面上指总共未被消费的值,这里指的是未被NestedChild消费的Y轴上的手指滑动距离,举个例子,当一个RecyclerView已经滑动到最顶部,如果手指继续下拉的话,实际上是滑不动的,所以此时手指的下拉距离都被算作是未被消费的值,之所以我们可以看到小圆圈跟随手指移动,正是与mTotalUnconsumed有关。mNestedScrollInProgress指的是当前是否处在嵌套滑动流程中,此变量在onNestedScrollAccepted方法中赋值为true,在onStopNestedScroll方法中赋值为false。关于这个变量存在的意义,这里需要补充的是,SwipeRefreshLayout并不仅仅可以通过嵌套滑动来完成下拉刷新,在子布局不支持NestedScroll的情况下(如5.0版本以前的绝大多数控件),通过传统的方式(intercept+ontouch)也可以实现相同的效果,我们看一段代码:

 @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //...
        if (!isEnabled() || mReturningToStart || canChildScrollUp()
                || mRefreshing || mNestedScrollInProgress) {
            return false;
        }       
        //...
    }

此段摘自SwipeRefreshLayout的onInterceptTouchEvent方法,我们可以注意到,当mNestedScrollInProgress为true时,SwipeRefreshLayout是不拦截事件的,也就意味着此时下拉操作交由嵌套滑动机制处理,如果子布局不支持嵌套滑动,mNestedScrollInProgress没有被赋值为true的机会,所以下拉操作交由onTouchEvent处理,通过这种方式就可以完美支持所有子布局了,这也是mNestedScrollInProgress存在的意义。

onNestedPreScroll

onNestedPreScroll的调用时机发生在子布局滑动之前,以RecyclerView的OnTouchEvent方法举例:

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            //...
            //我们只关注ACTION_MOVE事件
            case MotionEvent.ACTION_MOVE: {
                //...
                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                //此次手指滑动产生的原始偏移量
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;
                //在消费偏移量之前先将其派发给父消费,父View会将其消费的距离放置在mScrollConsumed中
                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                    //此时RecyclerView只能使用父View剩下的偏移量
                    //也就是将父View消费的距离从dx、dy中减去
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                    //...
                }
                //...
                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    //...
                    //RecyclerView根据当前的dx或dy的值进行滚动处理
                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            vtev)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    //...
                }
            } break;
            //...
        }
        //...
        return true;
    }

从OnTouchEvent中挑出了几行能说明问题的代码,大家根据注释应该很容易理解。在RecyclerView滑动之前,将调用dispatchNestedPreScroll方法,此方法通过NestedScrollingChildHelper的dispatchNestedPreScroll方法辗转调用到NestedScrollingParent(父布局)的onNestedPreScroll方法,在此方法中父布局按需求消费RecyclerView提供的dx、dy,并将消费的距离放入mScrollConsumed数组中。之后RecyclerView再消费父布局剩下的距离。举个RecyclerView跟ToolBar联动的例子,在一次ActionMove事件中,dy为50,正常情况下RecyclerView应该向下滚动50px,但是父View此时希望先把Toolbar拉下来再让RecyclerView滚动,所以假设Toolbar还有40px就全部拉下来了,那么父布局将消费40px,RecyclerView用剩下的10px进行滚动。当然了大部分情况下父布局要么消费全部dy,要么不消费dy,因为dy可能很小,上面的例子是处于边界状态的特殊情况。 下面我们来讨论一下SwipeRefreshLayout对onNestedPreScroll方法的处理

@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        //第一段
        if (dy > 0 && mTotalUnconsumed > 0) {
            if (dy > mTotalUnconsumed) {
                consumed[1] = dy - (int) mTotalUnconsumed;
                mTotalUnconsumed = 0;
            } else {
                mTotalUnconsumed -= dy;
                consumed[1] = dy;
            }
            moveSpinner(mTotalUnconsumed);
        }
        //第二段
        if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0
                && Math.abs(dy - consumed[1]) > 0) {
            mCircleView.setVisibility(View.GONE);
        }
        //第三段
        final int[] parentConsumed = mParentScrollConsumed;
        if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
            consumed[0] += parentConsumed[0];
            consumed[1] += parentConsumed[1];
        }
    }

代码量不大,是因为这个方法关注的重点是向上推的动作,准确的说,是小圆圈处在可上推状态时的手势处理。比如下拉拉到一半,不想刷新了,此时的上推处理。这个表述有点拗口,其实就是第一段中的if判断所做的事。dy大于零表示上推,mTotalUnconsumed大于0表示小圆圈已经被拉下来一部分了。如果两个条件成立,那么就会按照当前的下拉状况决定要消费到少距离。我们把这段代码摘出来重新讨论一下。

if (dy > mTotalUnconsumed) { consumed[1] = dy - (int) mTotalUnconsumed; mTotalUnconsumed = 0; } else { mTotalUnconsumed -= dy; consumed[1] = dy; }

这里的else分支没有什么问题,对应的情况是mTotalUnconsumed比dy大,此次上推的dy需要全部用来移动小圆圈,所以consumed[1] = dy。但在mTotalUnconsumed小于dy的情况下,我们来上一张图:
《SwipeRefreshLayout嵌套滑动源码分析》

讲道理,就这张图而言,dy中应该只有mTotalUnconsumed那一部分被用来移动小圆圈,所以consumed[1]应该等于mTotalUnconsumed才对吧?!而源码中写的是

consumed[1] = dy - (int) mTotalUnconsumed;

不知道这里是一个小bug还是我的理解有误,但其实不管哪种写法都不会让人明显的感觉出差异,因为毕竟这个分支只有在极少数的情况下才会触发,而且差异微弱。
之后的第二段为了处理自定义小圆圈Offset的情况,需要在小圆圈回到初始位置的情况下对其隐藏。
第三段的处理也是很重重要的,此时SwipeRefreshLayout又作为NestedChild向父布局传递事件,代码应该很好理解,无非是把自己用剩下的dx、dy传递给父布局供他们继续使用。他们消费的距离被放置在parentConsumed数组中,作为SwipeRefreshLayout消费的一部分供子布局做后续处理。但要注意的是,此段代码一定要书写在方法的最后,也就是一定要等自己将事件处理过之后再交由父布局处理。举一个Toolbar、RecyclerView、SwipeRefreshLayout联动的例子:
SwipeRefreshLayout包裹着RecyclerView,与Toolbar平级作为CoordinatorLayout的直接子View,此时RecyclerView向SwipeRefreshLayout派发PreScroll上推事件,SwipeRefreshLayout正处在上推过程中,需要消费dy来移动小圆圈,如果SwipeRefreshLayout在消费之前就将事件派发给父布局,那么,此时小圆圈还未完全收起,Toolbar就已经被推上去了,这将造成交互上的混乱。
最后讨论一下关于dispatchNestedPreScroll的第四个参数,关于这个参数,网上的相关资料也都是一笔带过,这里抛砖引玉,希望可以一起讨论。源码对其的注释为:

Optional. If not null, on return this will contain the offset in local view coordinates of this view from before this operation to after it completes.
View implementations may use this to adjust expected input coordinate tracking.

我的翻译是:可选参数,如果不是NULL,当dispatchNestedPreScroll执行完毕后,
此参数将包含该视图在屏幕中的偏移量,可以用来调整期望的坐标。

SwipeRefreshLayout选择将这个参数传递为null,个人的理解为此处无法直接操作MotionEvent,即使获取到了自己在屏幕中的偏移量,也无法通过MotionEvent来调整坐标,所以就忽略了这个参数。大家也可以查看一下RecyclerView的源码,也确实应用到了这个参数来调整MotionEvent的坐标。

onNestedScroll

onNestedScroll发生在dispatchNestedPreScroll之后,SwipeRefreshLayout主要通过此方法实现小圆圈的下拉操作,我们来看一下具体实现:

    @Override
    public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
                               final int dxUnconsumed, final int dyUnconsumed) {
        //向父布局派发NestedScroll事件
        dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                mParentOffsetInWindow);
        //尝试移动小圆圈
        final int dy = dyUnconsumed + mParentOffsetInWindow[1];
        if (dy < 0 && !canChildScrollUp()) {
            mTotalUnconsumed += Math.abs(dy);
            moveSpinner(mTotalUnconsumed);
        }
    }

眼尖的同学一眼就会发现两段代码的对称性,这次嵌套滑动事件的派发发生在消费之前,还是刚才的例子,因为在下拉过程中,要先保证Toolbar被拉下来,之后才是小圆圈,毕竟在下拉刷新的过程中,小圆圈的移动是有阻尼效果的,SwipeRefreshLayout给人的感觉一定是比Toolbar要难拉动的,所以是先派发,再消费。这里消费的dy,要去除父View消耗的偏移量,所谓的父View消耗的偏移量,其实是通过mParentOffsetInWindow这个参数,判断父View在屏幕中位移,来确认父View消耗了多少偏移量。之后就是,如果dy是向下拉动,同时子View已经拉动至最顶部,则移动小圆圈。额外多说一句,canChildScrollUp方法一般通过ViewCompat的canScrollVertically方法来判断子View是否已经拉动至最顶部,我们来看一下源码:

    public boolean canChildScrollUp() {
        if (mChildScrollUpCallback != null) {
            return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
        }
        if (android.os.Build.VERSION.SDK_INT < 14) {
            if (mTarget instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) mTarget;
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                        .getTop() < absListView.getPaddingTop());
            } else {
                return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(mTarget, -1);
        }
    }

我直接来梳理一下,在14版本以前,如果是ListView、GridView之类的AbsListView,则通过absListView相关方法进行处理,否则通过ViewCompat处理。在14版本以后,直接通过ViewCompat处理。那ViewCompat又是怎么处理的呢?在14版本以前,通过ScrollingView接口的方法的判断是否可纵向滑动,所以要求其必须实现ScrollingView接口,而14版本及以后,直接通过View类的canScrollVertically方法进行判断。为什么14版本之后就可以直接判断了呢,因为此时的View类已经加入了ScrollingView的相关方法签名。canScrollVertically还是通过ScrollingView的相关方法来判断是否可滑动。这就引出一个问题,如果某些自定义View并没有按照自身情况覆写ScrollingView的相关方法,可能导致canScrollVertically判断不准确,所以,SwipeRefreshLayout允许用户传入一个自定义的接口,以此来判断当前情况下子View是否可以纵向下拉,详见第一行条件判断。

关于fling

SwipeRefreshLayout自身并不消费fling操作,只是按约定将其派发到父View,代码如下

    @Override
    public boolean onNestedFling(View target, float velocityX, float velocityY,
            boolean consumed) {
        return dispatchNestedFling(velocityX, velocityY, consumed);
    }

    @Override
    public boolean onNestedPreFling(View target, float velocityX,
            float velocityY) {
        return dispatchNestedPreFling(velocityX, velocityY);
    }

如果父View的onNestedPreFling/onNestedFling返回了false(不消费fling),则SwipeRefreshLayout的onNestedPreFling/onNestedFling也就返回false,则SwipeRefreshLayout的嵌套滑动子View的fling效果不受影响,就像图中看到的那样:
《SwipeRefreshLayout嵌套滑动源码分析》
关于fling,我还是想吐槽一下,它完全没有看起来那么好用,从上面的动图大家可以注意到,Toolbar的与RecyclerView是同时进行滚动的,如果我们希望Toolbar先滚动,等Toolbar收起后RecyclerView用剩下的速度继续滚动是很难做到的,要么SwipeRefreshLayout吃掉所有的速度,不管能不能消耗干净,要么就是不消耗fling事件,表现为RecyclerView与NestedParent一起滚动。虽说一起滚动这种方案勉强可以接受,但并不是特别令人满意。

onStopNestedScroll

最后就是onStopNestedScroll,主要用于处理松手后小圆圈的动画效果,在小圆圈不位于初始位置的情况下,要么将其完全收起,要么就是将其移动至适当位置播放加载动画,源码如下

    @Override
    public void onStopNestedScroll(View target) {
        mNestedScrollingParentHelper.onStopNestedScroll(target);
        mNestedScrollInProgress = false;
        // Finish the spinner for nested scrolling if we ever consumed any
        // unconsumed nested scroll
        if (mTotalUnconsumed > 0) {
            //移动小圆圈
            finishSpinner(mTotalUnconsumed);
            mTotalUnconsumed = 0;
        }
        // Dispatch up our nested parent
        stopNestedScroll();
    }

大部分的工作都在finishSpinner方法中,这个方法我们就不贴代码了,也不是我们本文讨论的重点,最后调用了stopNestedScroll方法继续向父布局分发事件,这样整个嵌套滑动工作流程就结束了。

写在最后

NestedScrolling为我们提供了一套简单易用的事件处理机制,在兼容原有事件分发流程的基础之上,安全且优雅的实现了事件的反向分发(原本事件从父View向子View传递,NestedScrolling将事件从子View父View传递),为material design中各种炫酷的交互效果提供了有力的底层支持。但个人认为NestedScrolling对fling的处理太过草率,怎么说呢,就…我觉得很普通。 除此之外可以说NestedScrolling大大降低了开发难度,简化了自定义ViewGroup的开发。而且CoordinatorLayout将嵌套滑动进一步解耦,抽离出Behavior使得我们完全不需要面向自定义ViewGroup就可以实现各种炫酷效果,可以说是非常ok了。

    原文作者:Android源码分析
    原文地址: https://juejin.im/entry/59d253c05188256c4b727d06
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞