嵌套滚动,顾名思义,就是有至少两个可以滚动的view或者viewgroup,也就说父view和子view都可以滚动,而子view要滚动的时候就要通知父view,我要开始滚动了,由父view决定是否要帮助子view滚动一段距离,父view帮助子view滚动了多少,子view就少移动多少距离。

看了上面这段话,你是不是会想要怎么实现呢?作为Android开发人员应该很容易想到了View事件分发机制。那我们就先自己吓唬吓唬自己吧?猜想一下,用事件分发机制如何实现这种效果。

  1. 滑动子View,在父View的dispatchTouchEvent方法中进行事件分发。
  2. 在父View的onInterceptTouchEvent方法中,进行上下滑动方向判断。
  3. 如果header当前没还有隐藏,但用户在向上滑动,则由父View来处理事件,即onInterceptTouchEvent要返回true。
  • 如果header已经隐藏,并且用户在向下滑动,如果子View当前不能再向下滑动了(这句话是说子View中被隐藏的部分是否都已经显示出来了,当前是指列表的item是否都已经全部显示出来了),则由父View来处理事件,即onInterceptTouchEvent返回true。
  • 当父View拦截了事件后,接下来就在父View的onTouchEvent方法中进行header的滑动处理。
  • 如果父View不拦截事件,则由子View自己处理滑动事件,当然也是onInterceptTouchEvent和onTouchEvent的配合。上面只是粗略方案,看起来是可行的,但我们都知道,事件拦截机制是有弊端的。只要父view拦截了事件,接下来的一系列手势都有父View处理(down、move、up手势)。你需要在onInterceptTouchEvent和onTouchEvent都去处理down、move、up手势。根据MotionEvent取获取x和y坐标分别进行拦截判断和滑动处理。我感觉是非常麻烦的。其实Google的大神们应该是已经知道了我们这种痛苦。所以直接给了我们一个事件分发机制的简化版本(此处说法不是很准确)。那么他们是怎么实现这套机制的呢?
    他们的实现是这样的:


    当子View触发手势事件的时候,在onTouchEvent中会通知有某种特征的父View(这个特征待会再说),并且父View必须决定是否要处理某个事件,以及怎样处理某个事件。父View处理完了之后,子View再继续处理。
    貌似好空洞哈,我感觉也是,下面我们就通过源代码来分析这套机制的实现原理。
    在阅读源代码之前,得先要知道几个关键的接口或类,如下:


    NestedScrollingChild:支持滚动的子View需要实现一套接口。
    NestedScrollingChildHelper:将子View的滑动事件转发到相应的父View,让父View来处理事件。
    NestedScrollingParent:包括滚动子View的父View需要实现的接口。这玩意就是我们上面说的父View必须具有的特性,也就是说父View必须要实现这个接口,稍后的源代码中会看到解释的。
    NestedScrollingParentHelper:父View中会使用的辅助类。此类只有3个方法,基本没干啥事。



    知道了这几个接口或类之后,下面我们就开始分析源代码了,由于在我们的项目中使用的是RecyclerView,当RecyclerView滑动的时候,去显示或隐藏header。那么接下来我们就以RecyclerView为例来看看这套机制的实现原理:
    首先我们看看RecyclerView的继承体系,如下所示:
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild {}

从上面的代码可以看出,RecyclerView实现了NestedScrollingChild,那它就是事件的源头,也就代表着上面一直说的子View的角色。顺便说一下,此处的RecyclerView也实现了ScrollingView。这个很重要哈。在我的代码实现中就用到了这玩意来做判断。

RecyclerView的实现代码很多。但根据上面的分析,接下来我们直接去看RecyclerView的onTouchEvent方法了,这里是我们的战场。这个很重要。代码如下:


public boolean onTouchEvent(MotionEvent e) {
    final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
    final boolean canScrollVertically = mLayout.canScrollVertically();
 
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            mScrollPointerId = e.getPointerId(0);
            mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
            mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
 
            int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
            if (canScrollHorizontally) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
            }
            if (canScrollVertically) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
            }
            startNestedScroll(nestedScrollAxis);
        }
        break;
    }
    return true;
}

在上述代码中获取到RecyclerView支持的滚动方向。水平方向或者垂直方向。

· 在MotionEvent.ACTION_DOWN中,获取到RecyclerView滚动的方向。记录初始位置。然后调用startNestedScroll(nestedScrollAxis);代码如下:

public boolean startNestedScroll(int axes) {
    return getScrollingChildHelper().startNestedScroll(axes);
}

在该方法中会去调用getScrollingChildHelper().startNestedScroll(axes);将事件转发给父View。

getScrollingChildHelper()返回的就是NestedScrollingChildHelper(RecyclerView.this)。这里的参数是RecyclerView,在NestedScrollingChildHelper中是直接赋值给mView字段的,这很重要。代码如下:

private NestedScrollingChildHelper getScrollingChildHelper() {
    if (mScrollingChildHelper == null) {
        mScrollingChildHelper = new NestedScrollingChildHelper(this);
    }
    return mScrollingChildHelper;
}

下面我们看看NestedScrollingChildHelper的构造方法,代码如下:

public NestedScrollingChildHelper(View view) {
    mView = view;
}

看到了吧,这里的mView就是RecyclerView。这对于我们分析接下来的一系列方法的参数很有帮助。

NestedScrollingChildHelper的startNestedScroll方法是真正将事件传递到父View的地方。代码如下:

public boolean startNestedScroll(int axes) {
    if (hasNestedScrollingParent()) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                mNestedScrollingParent = p;
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

在NestedScrollingChildHelper的startNestedScroll方法中会去递归的寻找有特征的父View,此处调用了ViewParentCompat.onStartNestedScroll(ViewParent parent, View child, View target,int nestedScrollAxes)方法。若找到父View,则将父View记录到变量mNestedScrollingParent 中,在接下来的事件中直接使用。如果有找到父View,并且父View的onStartNestedScroll方法返回true(代码父View接受滑动事件,比如父View只接受垂直滑动事件,就可以根据坐标轴进行方向判断是否是垂直方向,并返回true),还会调用ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes)方法。

ViewParentCompat.onStartNestedScroll方法的实现是这样的:

1. api版本低于5.0的就判断parent是否实现了NestedScrollingParent接口。

2. 在5.0及以后的版本中直接调用了ViewParent中的onStartNestedScroll方法。

3. 1和2都是调用onStartNestedScroll方法,告诉父View开始滚动了。

这里有必要说明下:

我们都知道ViewGroup继承自ViewParent。代码如下:

public abstract class ViewGroup extends View implements ViewParent, ViewManager

在5.0之前,支持嵌套滚动的父View都必须实现NestedScrollingParent,但5.0之后,NestedScrollingParent接口中的方法,在ViewParent中全部都有声明。由此可见Google对嵌套滚动的重视。

在继续分析ViewParentCompat.onStartNestedScroll方法实现之前,有必要解释下它的几个参数都是啥意思。这个从上面的while循环中可以得出:

· parent:是实现了NestedScrollingParent或者5.0之后版本的ViewGroup

· child:是parent的直接子View

· target:就是构造NestedScrollingChildHelper传递进来的RecyclerView,如果parent直接包含了RecyclerView。那么child和target相同。

· nestedScrollAxes:是RecyclerView滚动时的方向。

下面来看看ViewParentCompat.onStartNestedScroll的实现,5.0之前的实现如下:

@Override
public boolean onStartNestedScroll(ViewParent parent, View child, View target,
        int nestedScrollAxes) {
    if (parent instanceof NestedScrollingParent) {
        return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                nestedScrollAxes);
    }
    return false;
}

上述代码就是判断父view是否实现了NestedScrollingParent接口。

在5.0之后的代码实现如下:

//ViewParentCompat.ViewParentCompatLollipopImpl静态类中的代码
@Override
public boolean onStartNestedScroll(ViewParent parent, View child, View target,
        int nestedScrollAxes) {
    return ViewParentCompatLollipop.onStartNestedScroll(parent, child, target,
            nestedScrollAxes);
}
 
//ViewParentCompatLollipop的代码
public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
        int nestedScrollAxes) {
    try {
        return parent.onStartNestedScroll(child, target, nestedScrollAxes);
    } catch (AbstractMethodError e) {
        Log.e(TAG, "ViewParent " + parent + " does not implement interface " +
                "method onStartNestedScroll", e);
        return false;
    }
}

上述代码就直接调用了ViewParent的onStartNestedScroll方法的。

说明:由于我们这次的滚动父View实现是直接继承自NestedScrollingParent。所以接下来的分析只针对5.0之前代码,5.0之后的代码不做分析。

总结一下ACTION_DOWN做了什么事:

1. 获取到滑动方向。

2. 调用辅助类NestedScrollingChildHelper的startNestedScroll方法,去寻找父View,然后调用父View的onStartNestedScroll和onNestedScrollAccepted方法。

接下来我们分析ACTION_MOVE的实现,代码如下:

@Override
public boolean onTouchEvent(MotionEvent e) {
    switch (action) {
        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;
 
            if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                dx -= mScrollConsumed[0];
                dy -= mScrollConsumed[1];
                vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
                // Updated the nested offsets
                mNestedOffsets[0] += mScrollOffset[0];
                mNestedOffsets[1] += mScrollOffset[1];
            }
 
            if (mScrollState == SCROLL_STATE_DRAGGING) {
                mLastTouchX = x - mScrollOffset[0];
                mLastTouchY = y - mScrollOffset[1];
 
                if (scrollByInternal(
                    canScrollHorizontally ? dx : 0,
                    canScrollVertically ? dy : 0,
                vtev)) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
            }
        }
        break;
    }
    return true;
}

从上面的代码可以看出,首先计算出当前滑动的距离dx和dy。然后调用dispatchNestedPreScroll方法。这个方法的前三个参数是最重要的。第三个参数是个数组。也是最最重要的。有两个元素,第1个元素说明父View在x轴上消费的距离。第2个元素说明父View在y轴上消费的距离。这里的消费就是我们所说的父View背着子View滚动的距离。这一点可以从dx -= mScrollConsumed[0];和 dy -= mScrollConsumed[1];看出。此处将父View滚动的距离减掉。然后子View自己滚动剩下的距离。上述代码的scrollByInternal就是子View滚动剩下的距离。第4个参数是为了矫正子View在屏幕中的位置而使用的,我们不用考虑。

dispatchNestedPreScroll方法的实现最终也是调用NestedScrollingChildHelper的dispatchNestedPreScroll方法。代码如下:

public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
    if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
        if (dx != 0 || dy != 0) {
            int startX = 0;
            int startY = 0;
            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                startX = offsetInWindow[0];
                startY = offsetInWindow[1];
            }
 
            if (consumed == null) {
                if (mTempNestedScrollConsumed == null) {
                    mTempNestedScrollConsumed = new int[2];
                }
                consumed = mTempNestedScrollConsumed;
            }
            consumed[0] = 0;
            consumed[1] = 0;
            ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
 
            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                offsetInWindow[0] -= startX;
                offsetInWindow[1] -= startY;
            }
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            offsetInWindow[0] = 0;
            offsetInWindow[1] = 0;
        }
    }
    return false;
}

在该方法中,主要做了两件事:

1. 计算mView的位置,并做相应调整,不需要关心。我也不知道为啥?

2. 通过ViewParentCompat.onNestedPreScroll方法,并调用父View的onNestedPreScroll方法。主要就是想知道父View是否消费了某个方向的距离。如果父View有消费某个方向上的距离,整个方法就返回true。只有返回true,ACTION_MOVE中的dx和dy才会进行-=操作。

在ACTION_MOVE中还有一段代码很重要,那就是当子View当前处于拖拽状态时(mScrollState == SCROLL_STATE_DRAGGING)会执行的方法,那就是scrollByInternal方法。该方法的代码如下:

boolean scrollByInternal(int x, int y, MotionEvent ev) {
    int unconsumedX = 0, unconsumedY = 0;
    int consumedX = 0, consumedY = 0;
 
    consumePendingUpdateOperations();
    if (mAdapter != null) {
        eatRequestLayout();
        onEnterLayoutOrScroll();
        TraceCompat.beginSection(TRACE_SCROLL_TAG);
        if (x != 0) {
            consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
            unconsumedX = x - consumedX;
        }
        if (y != 0) {
            consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
            unconsumedY = y - consumedY;
        }
        TraceCompat.endSection();
        repositionShadowingViews();
        onExitLayoutOrScroll();
        resumeRequestLayout(false);
    }
    if (!mItemDecorations.isEmpty()) {
        invalidate();
    }
 
    if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)) {
        // Update the last touch co-ords, taking any scroll offset into account
        mLastTouchX -= mScrollOffset[0];
        mLastTouchY -= mScrollOffset[1];
        if (ev != null) {
            ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
        }
        mNestedOffsets[0] += mScrollOffset[0];
        mNestedOffsets[1] += mScrollOffset[1];
    } else if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
        if (ev != null) {
            pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
        }
        considerReleasingGlowsOnScroll(x, y);
    }
    if (consumedX != 0 || consumedY != 0) {
        dispatchOnScrolled(consumedX, consumedY);
    }
    if (!awakenScrollBars()) {
        invalidate();
    }
    return consumedX != 0 || consumedY != 0;
}

该方法内部,主要做了3件事:

1. 让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。

2. 计算出子View当前以及滚动的距离和未滚动的距离。

3. 根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的

dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。

注意:上述方法中的已滚动距离和未滚动距离都是相对于子View的dx或dy的。scrollByInternal的参数x和y就是dx和dy。consumed和unconsumed都是在dx或dy的基础上进行计算的。

下面我们接着分析ACTION_UP事件,代码如下:

该方法内部,主要做了3件事:
1.让子View沿着水平或者垂直方向,将剩下的dx和dy滚动完。
2.计算出子View当前以及滚动的距离和未滚动的距离。
3.根据子View已经滚动的距离和未滚动的距离调用dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset)方法。当然这里和上面的
dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。
4.
注意:上述方法中的已滚动距离和未滚动距离都是相对于子View的dx或dy的。scrollByInternal的参数x和y就是dx和dy。consumed和unconsumed都是在dx或dy的基础上进行计算的。
下面我们接着分析ACTION_UP事件,代码如下:

在上述代码中,进行了水平和垂直方向上的滑动速度判断,如果有一个速度不等于0,就代表快速滑动,会调用fling((int) xvel, (int) yvel))方法。代码如下:

public boolean fling(int velocityX, int velocityY) {
    final boolean canScrollHorizontal = mLayout.canScrollHorizontally();
    final boolean canScrollVertical = mLayout.canScrollVertically();
 
    if (!canScrollHorizontal || Math.abs(velocityX) < mMinFlingVelocity) {
        velocityX = 0;
    }
    if (!canScrollVertical || Math.abs(velocityY) < mMinFlingVelocity) {
        velocityY = 0;
    }
    if (velocityX == 0 && velocityY == 0) {
        // If we don't have any velocity, return false
        return false;
    }
 
    if (!dispatchNestedPreFling(velocityX, velocityY)) {
        final boolean canScroll = canScrollHorizontal || canScrollVertical;
        dispatchNestedFling(velocityX, velocityY, canScroll);
 
        if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
            return true;
        }
 
        if (canScroll) {
            velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
            velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
            mViewFlinger.fling(velocityX, velocityY);
            return true;
        }
    }
    return false;
}

在该方法,主要做了4件事:

1. 根据滑动方向判断速度值的范围,是否小于最小值,如果小于则直接返回。

2. 调用dispatchNestedPreFling(velocityX, velocityY)方法,将速度值转发到父View的onNestedPreFling方法,由父View来决定是否要处理快速滑动事件。如果父View不处理快速滑动事件,则继续调用父View的

dispatchNestedFling(velocityX, velocityY, canScroll)方法。

3. 调用mOnFlingListener.onFling(velocityX, velocityY)方法,用于对齐滚动(左对齐、居中、右对齐)。它的一个实现类是LinearSnapHelper。举个例子就能明白:在水平滚动的RecycleView。如果第一个item向左滚动了2/3,那么我们就选中第二个item,将第一个item完全一次屏幕。

4. 调用mViewFlinger.fling(velocityX, velocityY)方法。并通过调用ScrollerCompat.fling()方法让子View平滑的滚动到相应位置。

ACTION_UP处理完快速滑动事件后,会调用resetTouch方法,代码如下:

private void resetTouch() {
    if (mVelocityTracker != null) {
        mVelocityTracker.clear();
    }
    stopNestedScroll();
    releaseGlows();
}

在此方法中,最重要的就是调用了stopNestedScroll()方法,该方法的目的就是通知父View滚动停止了。会调用父View的onStopNestedScroll()方法。在该方法中我们可以做些收尾工作。比如让滚动了2/3的view完全滚出屏幕等。

好了,到目前位置父View实现的NestedScrollingParent的接口中方法,是在何时被回调我们都知道了,接下来就要开始实战了。