Android NestedScrolling机制深入解析

概述

NestedScrolling是Android 5.0之后为我们提供的新特性,降低了使用传统事件分发机制处理嵌套滑动的难度,用于给子view与父view提供更好的交互。

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

传统的嵌套事件,我们滑动子view的内容区域,而移动却是外部的ViewGroup,所以传统的方式肯定是外部的ViewGroup拦截了内部的Child的事件,但是在Parent滑动到一定程度时,Chlid又开始滑动,中间的过程没有间断。从正常的事件分发(不手动分发)是不能完成的,因为Parent拦截后,就没有办法再把事件交给Child的(拦截的是一个事件序列)

主要类

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

1、NestedScrollingChild

public interface NestedScrollingChild {
   /**
     * 设置嵌套滑动是否能用
     *
     * @param enabled
     */
    void setNestedScrollingEnabled(boolean enabled);

    /**
     * 判断嵌套滑动是否可用
     *
     * @return
     */
    boolean isNestedScrollingEnabled();

    /**
     * 开始嵌套滑动
     *
     * @param axes 表示方向轴,有横向和竖向
     * @return
     */
    boolean startNestedScroll(int axes);


    /**
     * 停止嵌套滑动
     */
    void stopNestedScroll();

    /**
     * 判断是否有父View 支持嵌套滑动
     *
     * @return
     */
    boolean hasNestedScrollingParent();

    /**
     * 子view处理scroll后调用
     *
     * @param dxConsumed     x轴上被消费的距离(横向)
     * @param dyConsumed     y轴上被消费的距离(竖向)
     * @param dxUnconsumed   x轴上未被消费的距离
     * @param dyUnconsumed   y轴上未被消费的距离
     * @param offsetInWindow 子View的窗体偏移量
     * @return true if the event was dispatched, false if it could not be dispatched.
     */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
                                 int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

    /**
     * 在子View的onInterceptTouchEvent或者onTouch中,调用该方法通知父View滑动的距离
     *
     * @param dx             x轴上滑动的距离
     * @param dy             y轴上滑动的距离
     * @param consumed       父view消费掉的scroll长度
     * @param offsetInWindow 子View的窗体偏移量
     * @return 支持的嵌套的父View 是否处理了 滑动事件
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
                                    @Nullable int[] offsetInWindow);

    /**
     * 滑行时调用
     *
     * @param velocityX x 轴上的滑动速率
     * @param velocityY y 轴上的滑动速率
     * @param consumed  是否被消费
     * @return true if the nested scrolling parent consumed or otherwise reacted to the fling
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

    /**
     * 进行滑行前调用
     *
     * @param velocityX x 轴上的滑动速率
     * @param velocityY y 轴上的滑动速率
     * @return true if a nested scrolling parent consumed the fling
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}

2、NestedScrollingParent

public interface NestedScrollingParent {
    /**
     * 当 Scrolling Child 调用 onStartNestedScroll 方法的时候,通过 NestedScrollingChildHelper
     * 会回调 Scrolling parent 的 onStartNestedScroll 方法,如果返回 true,
     * Scrolling parent 的 onNestedScrollAccepted(View child, View target, int nestedScrollAxes)
     * 方法会被回调。
     *
     * @param child 包含此目标的ViewParent的直接view
     * @param target 初始化嵌套滚动的view
     * @param nestedScrollAxes 滚动方向:ViewCompat#SCROLL_AXIS_HORIZONTAL,ViewCompat#SCROLL_AXIS_VERTICAL
     * @return
     */
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);


    /**
     * 如果 Scrolling Parent 的onStartNestedScroll 返回 true,
     * Scrolling parent 的 onNestedScrollAccepted(View child, View target,
     * int nestedScrollAxes) 方法会被回调。
     * @param child
     * @param target
     * @param nestedScrollAxes
     */
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

    /**
     * 嵌套滚动结束后调用
     * @param target
     */
    public void onStopNestedScroll(View target);

    /**
     * 正在嵌套滚动
     * 要接收对此方法的调用,ViewParent必须先前在调用onStartNestedScroll时返回true
     * @param target 嵌套滚动的child view
     * @param dxConsumed 目标已消耗的水平滚动距离
     * @param dyConsumed 目标已消耗的垂直滚动距离
     * @param dxUnconsumed 目标未消耗的水平滚动距离
     * @param dyUnconsumed 目标未消耗的垂直滚动距离
     */
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
                               int dxUnconsumed, int dyUnconsumed);

    /**
     * 当 Scrolling Child 调用 dispatchNestedPreScroll 方法的时候调用此方法
     * @param target Scrolling Child
     * @param dx 水平滚动距离(像素)
     * @param dy 垂直滚动距离(像素)
     * @param consumed  Output. The horizontal and vertical scroll distance consumed by this parent
     */
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

    public boolean onNestedPreFling(View target, float velocityX, float velocityY);

    public int getNestedScrollAxes();
}

NestedScrollingChildNestedScrollingParent分别定义了嵌套子View和嵌套父View需要实现的接口。另外这些方法基本都是通过NestedScrollingChildHelperNestedScrollingParentHelper来实现,一般并不需要手动编写多少逻辑。

通过方法名可以看出,NestedScrollingChild的方法均为主动方法,而NestedScrollingParent的方法基本都是回调方法。这也是NestedScrolling机制的一个体现,子View作为NestedScrolling事件传递的主动方,父View作为被动方。

NestedScrolling机制生效的前提条件是子View作为Touch事件的消费者,在消费过程中向父View发送NestedScrolling事件(注意这里不是Touch事件,而是NestedScrolling事件。

NestedScrolling事件传递

NestedScrolling机制中,NestedScrolling事件使用dx, dy表示,分别表示子View Touch事件处理方法中判定的x和y方向上的滚动偏移量。

NestedScrolling事件的传递:

  1. 由子View产生NestedScrolling事件;
  2. 发送给父View进行处理,父View处理之后,返回消费的偏移量;
  3. 子View根据父View消费的偏移量计算NestedScrolling事件剩余偏移量;
  4. 根据剩余偏移量判断是否能处理滚动事件;如果处理滚动事件,同时将自身滚动情况通知父View;
  5. 处理结束,事件传递完成。

这里只说明了一层嵌套的情况,事实上NestedScrolling很可能出现在多重嵌套的场景。对于多重嵌套,步骤2、3、4将事件自底向上进行传递,步骤2中消费的偏移量将记录所有嵌套父View消费偏移量的总和。这里不再重复。

下面以RecyclerView为例,看一下NestedScrolling事件的传递过程

1、初始阶段

确认开启NestedScrolling,关联父View和子View。

//NestedScrollingChild
void setNestedScrollingEnabled(boolean enabled);
boolean startNestedScroll(int axes);

//NestedScrollingParent
boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);
void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

RecyclerView实现了NestedScrollingChild,那它就是事件的源头。
直接去看RecyclerViewonTouchEvent方法:

    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;

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

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

getScrollingChildHelper()返回的就是NestedScrollingChildHelper。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)方法。

2、预滚动阶段

子View将事件分发到父View

// NestedScrollingChild
boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow);

// NestedScrollingParent
void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

接下来看看RecyclerViewonTouchEventACTION_MOVE事件的实现,代码如下:

case MotionEvent.ACTION_MOVE: {
        final int index = e.findPointerIndex(mScrollPointerId);
   
        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);
            }
            if (mGapWorker != null && (dx != 0 || dy != 0)) {
                mGapWorker.postFromTraversal(this, dx, dy);
            }
        }
    } break;

首先计算出当前滑动的距离dx和dy。然后调用dispatchNestedPreScroll方法。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;
    }

通过ViewParentCompat.onNestedPreScroll方法,并调用父View的onNestedPreScroll方法。主要就是想知道父View是否消费了某个方向的距离。如果父View有消费某个方向上的距离,整个方法就返回true

3、滚动阶段

子View处理滚动事件。
RecyclerView的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方法。当然这里和上面的dispatchNestedPreScroll方法类似,最终也是会调用到父View的onNestedScroll方法的。

4、结束阶段

// NestedScrollingChild
void stopNestedScroll();

// NestedScrollingParent
void onStopNestedScroll(View target);

最后再看一下RecyclerViewACTION_UP事件:

    case MotionEvent.ACTION_UP: {
        mVelocityTracker.addMovement(vtev);
        eventAddedToVelocityTracker = true;
        mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
        final float xvel = canScrollHorizontally ?
                -VelocityTrackerCompat.getXVelocity(mVelocityTracker, mScrollPointerId) : 0;
        final float yvel = canScrollVertically ?
                -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId) : 0;
        if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
            setScrollState(SCROLL_STATE_IDLE);
        }
        resetTouch();
    } break;
    private void resetTouch() {
        if (mVelocityTracker != null) {
            mVelocityTracker.clear();
        }
        stopNestedScroll();
        releaseGlows();
    }

ACTION_UP中会调用resetTouch方法。在此方法中,最重要的就是调用了stopNestedScroll()方法,该方法的目的就是通知父View滚动停止了。会调用父View的onStopNestedScroll()方法

当子View调用startNestedScroll方法时,开始嵌套滚动流程;之后不断循环pre-scroll和scroll两个过程(一般在子View的onTouchEvent的MOVE分支调用);直到手指抬起,子View调用stopNestedScroll方法结束滚动(在结束之前可能进入Fling状态)。

参考:
1、NestedScrolling嵌套滚动原理
2、NestedScrolling事件机制源码解析
3、NestedScrolling 机制深入解析
4、Android Nested Scrolling

你可能感兴趣的:(Android NestedScrolling机制深入解析)