11

透过 NestedScrollView 源码解析嵌套滑动原理

 3 years ago
source link: http://www.cnblogs.com/huansky/p/12897541.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

NestedScrollView 是用于替代 ScrollView 来解决嵌套滑动过程中的滑动事件的冲突。作为开发者,你会发现很多地方会用到嵌套滑动的逻辑,比如下拉刷新页面,京东或者淘宝的各种商品页面。

那为什么要去了解 NestedScrollView 的源码呢?那是因为 NestedScrollView 是嵌套滑动实现的模板范例,通过研读它的源码,能够让你知道如何实现嵌套滑动,然后如果需求上 NestedScrollView 无法满足的时候,你可以自定义。

嵌套滑动

说到嵌套滑动,就得说说这两个类了:NestedScrollingParent3 和 NestedScrollingChild3 ,当然同时也存在后面不带数字的类。之所以后面带数字了,是为了解决之前的版本遗留的问题:fling 的时候涉及嵌套滑动,无法透传到另一个View 上继续 fling,导致滑动效果大打折扣 。

其实 NestedScrollingParent2 相比 NestedScrollingParent 在方法调用上多了一个参数 type,用于标记这个滑动是如何产生的。type 的取值如下:

    /**
     * Indicates that the input type for the gesture is from a user touching the screen. 触摸产生的滑动
     */
    public static final int TYPE_TOUCH = 0;

    /**
     * Indicates that the input type for the gesture is caused by something which is not a user
     * touching a screen. This is usually from a fling which is settling.  简单理解就是fling
     */
    public static final int TYPE_NON_TOUCH = 1;

嵌套滑动,说得通俗点就是子 view 和 父 view 在滑动过程中,互相通信决定某个滑动是子view 处理合适,还是 父view 来处理。所以, Parent 和 Child 之间存在相互调用,遵循下面的调用关系:

rqiEvay.png!web

上图可以这么理解:

  • ACTION_DOWN 的时候子 view 就要调用 startNestedScroll( ) 方法来告诉父 view 自己要开始滑动了(实质上是寻找能够配合 child 进行嵌套滚动的 parent),parent 也会继续向上寻找能够配合自己滑动的 parent,可以理解为在做一些准备工作 。
  • 父 view 会收到 onStartNestedScroll 回调从而决定是不是要配合子 view 做出响应。如果需要配合,此方法会返回 true。继而 onStartNestedScroll()回调会被调用。
  • 在滑动事件产生但是子 view 还没处理前可以调用 dispatchNestedPreScroll(0,dy,consumed,offsetInWindow) 这个方法把事件传给父 view,这样父 view 就能在onNestedPreScroll 方法里面收到子 view 的滑动信息,然后做出相应的处理把处理完后的结果通过 consumed 传给子 view。

  • dispatchNestedPreScroll()之后,child可以进行自己的滚动操作。

  • 如果父 view 需要在子 view 滑动后处理相关事件的话可以在子 view 的事件处理完成之后调用 dispatchNestedScroll 然后父 view 会在 onNestedScroll 收到回调。

  • 最后,滑动结束,调用 onStopNestedScroll() 表示本次处理结束。

  • 但是,如果滑动速度比较大,会触发 fling, fling 也分为 preFling 和 fling 两个阶段,处理过程和 scroll 基本差不多。

NestedScrollView

首先是看类的名字

 class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
 NestedScrollingChild3, ScrollingView {

可以发现它继承了 FrameLayout,相当于它就是一个 ViewGroup,可以添加子 view , 但是需要注意的事,它只接受一个子 view,否则会报错。


    @Override
    public void addView(View child) {
        if (getChildCount() > 0) {
            throw new IllegalStateException("ScrollView can host only one direct child");
        }

        super.addView(child);
    }

    @Override
    public void addView(View child, int index) {
        if (getChildCount() > 0) {
            throw new IllegalStateException("ScrollView can host only one direct child");
        }

        super.addView(child, index);
    }

    @Override
    public void addView(View child, ViewGroup.LayoutParams params) {
        if (getChildCount() > 0) {
            throw new IllegalStateException("ScrollView can host only one direct child");
        }

        super.addView(child, params);
    }

    @Override
    public void addView(View child, int index, ViewGroup.LayoutParams params) {
        if (getChildCount() > 0) {
            throw new IllegalStateException("ScrollView can host only one direct child");
        }

        super.addView(child, index, params);
    }

add view

对于 NestedScrollingParent3,NestedScrollingChild3 的作用,前文已经说了,如果还是不理解,后面再对源码的分析过程中也会分析到。

其实这里还可以提一下 RecyclerView:

public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 {

这里没有继承 NestedScrollingParent3 是因为开发者觉得 RecyclerView 适合做一个子类。并且它的功能作为一个列表去展示,也就是不适合再 RecyclerView 内部去做一些复杂的嵌套滑动之类的。这样 RecycylerView 外层就可以再嵌套一个 NestedScrollView 进行嵌套滑动了。后面再分析嵌套滑动的时候,也会把 RecycylerView 当作子类来进行分析,这样能更好的理解源码。

内部有个接口,使用者需要对滑动变化进行监听的,可以添加这个回调:

    public interface OnScrollChangeListener {
        /**
         * Called when the scroll position of a view changes.
         *
         * @param v The view whose scroll position has changed.
         * @param scrollX Current horizontal scroll origin.
         * @param scrollY Current vertical scroll origin.
         * @param oldScrollX Previous horizontal scroll origin.
         * @param oldScrollY Previous vertical scroll origin.
         */
        void onScrollChange(NestedScrollView v, int scrollX, int scrollY,
                int oldScrollX, int oldScrollY);
    }

构造函数

下面来看下构造函数:

    public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs,
            int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initScrollView();

        final TypedArray a = context.obtainStyledAttributes(
                attrs, SCROLLVIEW_STYLEABLE, defStyleAttr, 0);
        // 是否要铺满全屏
        setFillViewport(a.getBoolean(0, false));

        a.recycle();
        // 即是子类,又是父类
        mParentHelper = new NestedScrollingParentHelper(this);
        mChildHelper = new NestedScrollingChildHelper(this);

        // ...because why else would you be using this widget? 默认是滚动,不然你使用它就没有意义了
        setNestedScrollingEnabled(true);

        ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE);
    }    

这里我们用了两个辅助类来帮忙处理嵌套滚动时候的一些逻辑处理,NestedScrollingParentHelper,NestedScrollingChildHelper。这个是和前面的你实现的接口 NestedScrollingParent3,NestedScrollingChild3 相对应的。

下面看下  initScrollView 方法里的具体逻辑:

    private void initScrollView() {
        mScroller = new OverScroller(getContext());
        setFocusable(true);
        setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
     // 会调用 ViewGroup 的 onDraw
        setWillNotDraw(false);
        // 获取 ViewConfiguration 中一些配置,包括滑动距离,最大最小速率等等
        final ViewConfiguration configuration = ViewConfiguration.get(getContext());
        mTouchSlop = configuration.getScaledTouchSlop();
        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
    }

setFillViewport

在构造函数中,有这么一个设定:

setFillViewport(a.getBoolean(0, false));

与 setFillViewport 对应的属性是 android:fillViewport="true"。如果不设置这个属性为 true,可能会出现如下图一样的问题:

xml 布局:

<?xml version="1.0" encoding="utf-8"?>
<NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#fff000">
        <Button
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </LinearLayout>
</NestedScrollView>

效果:

Z32mEjz.png!web

可以发现这个没有铺满全屏,可是 xml 明明已经设置了 match_parent 了。这是什么原因呢?

那为啥设置 true 就可以了呢?下面来看下它的 onMeasure 方法:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // false 直接返回
        if (!mFillViewport) {
            return;
        }

        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (heightMode == MeasureSpec.UNSPECIFIED) {
            return;
        }

        if (getChildCount() > 0) {
            View child = getChildAt(0);
            final NestedScrollView.LayoutParams lp = (LayoutParams) child.getLayoutParams();

            int childSize = child.getMeasuredHeight();
            int parentSpace = getMeasuredHeight()
                    - getPaddingTop()
                    - getPaddingBottom()
                    - lp.topMargin
                    - lp.bottomMargin;
            // 如果子 view 高度小于 父 view 高度,那么需要重新设定高度
            if (childSize < parentSpace) {
                int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
                        getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin,
                        lp.width);
                // 这里生成 MeasureSpec 传入的是 parentSpace,并且用的是 MeasureSpec.EXACTLY 
                int childHeightMeasureSpec =
                        MeasureSpec.makeMeasureSpec(parentSpace, MeasureSpec.EXACTLY);
                child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }
    }

当你将 mFillViewport 设置为 true 后,就会把父 View 高度给予子 view 。可是这个解释了设置 mFillViewport 可以解决不能铺满屏幕的问题,可是没有解决为啥 match_parent 无效的问题。

在回到类的继承关系上,NestedScrollView 继承的是 FrameLayout,也就是说,FrameLayout 应该和 NestedScrollView 拥有一样的问题。可是当你把 xml 中的布局换成 FrameLayout 后,你发现竟然没有问题。那么这是为啥呢?

原因是 NestedScrollView 又重写了 measureChildWithMargins 。子view 的 childHeightMeasureSpec 中的 mode 是 MeasureSpec.UNSPECIFIED 。当被设置为这个以后,子 view 的高度就完全是由自身的高度决定了。

    @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        // 在生成子 view 的 MeasureSpec 时候,传入的是 MeasureSpec.UNSPECIFIED
        final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

比如子 view 是 LinearLayout ,这时候,它的高度就是子 view 的高度之和。而且,这个 MeasureSpec.UNSPECIFIED 会一直影响着后面的子子孙孙 view 。

我猜这么设计的目的是因为你既然使用了 NestedScrollView,就没必要在把子 View  搞得跟屏幕一样大了,它该多大就多大,不然你滑动的时候,看见一大片空白体验也不好啊。

而 ViewGroup 中,measureChildWithMargins 的方法是这样的:

    protected void measureChildWithMargins(View child,
            int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                        + heightUsed, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

由于一般使用 NestedScrollView 的时候,都是会超过屏幕高度的,所以不设置这个属性为 true 也没有关系。

绘制

既然前面已经把 onMeasure 讲完了,那索引把绘制这块都讲了把。下面是 draw 方法,这里主要是绘制边界的阴影:

    @Override
    public void draw(Canvas canvas) {
        super.draw(canvas);
        if (mEdgeGlowTop != null) {
            final int scrollY = getScrollY();
       // 上边界阴影绘制
            if (!mEdgeGlowTop.isFinished()) {
                final int restoreCount = canvas.save();
                int width = getWidth();
                int height = getHeight();
                int xTranslation = 0;
                int yTranslation = Math.min(0, scrollY);
                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || getClipToPadding()) {
                    width -= getPaddingLeft() + getPaddingRight();
                    xTranslation += getPaddingLeft();
                }
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && getClipToPadding()) {
                    height -= getPaddingTop() + getPaddingBottom();
                    yTranslation += getPaddingTop();
                }
                canvas.translate(xTranslation, yTranslation);
                mEdgeGlowTop.setSize(width, height);
                if (mEdgeGlowTop.draw(canvas)) {
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                canvas.restoreToCount(restoreCount);
            }
       // 底部边界阴影绘制
            if (!mEdgeGlowBottom.isFinished()) {
                final int restoreCount = canvas.save();
                int width = getWidth();
                int height = getHeight();
                int xTranslation = 0;
                int yTranslation = Math.max(getScrollRange(), scrollY) + height;
                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || getClipToPadding()) {
                    width -= getPaddingLeft() + getPaddingRight();
                    xTranslation += getPaddingLeft();
                }
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && getClipToPadding()) {
                    height -= getPaddingTop() + getPaddingBottom();
                    yTranslation -= getPaddingBottom();
                }
                canvas.translate(xTranslation - width, yTranslation);
                canvas.rotate(180, width, 0);
                mEdgeGlowBottom.setSize(width, height);
                if (mEdgeGlowBottom.draw(canvas)) {
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                canvas.restoreToCount(restoreCount);
            }
        }
    }

onDraw 是直接用了父类的,这个没啥好讲的,下面看看 onLayout:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        mIsLayoutDirty = false;
        // Give a child focus if it needs it
        if (mChildToScrollTo != null && isViewDescendantOf(mChildToScrollTo, this)) {
            scrollToChild(mChildToScrollTo);
        }
        mChildToScrollTo = null;

        if (!mIsLaidOut) { // 是否是第一次调用onLayout
            // If there is a saved state, scroll to the position saved in that state.
            if (mSavedState != null) {
                scrollTo(getScrollX(), mSavedState.scrollPosition);
                mSavedState = null;
            } // mScrollY default value is "0"

            // Make sure current scrollY position falls into the scroll range.  If it doesn't,
            // scroll such that it does.
            int childSize = 0;
            if (getChildCount() > 0) {
                View child = getChildAt(0);
                NestedScrollView.LayoutParams lp = (LayoutParams) child.getLayoutParams();
                childSize = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            }
            int parentSpace = b - t - getPaddingTop() - getPaddingBottom();
            int currentScrollY = getScrollY();
            int newScrollY = clamp(currentScrollY, parentSpace, childSize);
            if (newScrollY != currentScrollY) {
                scrollTo(getScrollX(), newScrollY);
            }
        }

        // Calling this with the present values causes it to re-claim them
        scrollTo(getScrollX(), getScrollY());
        mIsLaidOut = true;
    }

onLayout 方法也没什么说的,基本上是用了父类 FrameLayout 的布局方法,加入了一些 scrollTo 操作滑动到指定位置。

嵌套滑动分析

如果对滑动事件不是很清楚的小伙伴可以先看看这篇文章: Android View 的事件分发原理解析

在分析之前,先做一个假设,比如 RecyclerView 就是 NestedScrollView 的子类,这样去分析嵌套滑动更容易理解。这时候,用户点击 RecyclerView 触发滑动。需要分析整个滑动过程的事件传递。

dispatchTouchEvent

这里,NestedScrollView 用的是父类的处理,并没有添加自己的逻辑。

onInterceptTouchEvent

当事件进行分发前,ViewGroup 首先会调用 onInterceptTouchEvent 询问自己要不要进行拦截,不拦截,就会分发传递给子 view。一般来说,对于 ACTION_DOWN 都不会拦截,这样子类有机会获取事件,只有子类不处理,才会再次传给父 View 来处理。下面来看看其具体代码逻辑:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        /*
         * This method JUST determines whether we want to intercept the motion.
         * If we return true, onMotionEvent will be called and we do the actual
         * scrolling there.
         */

        /*
        * Shortcut the most recurring case: the user is in the dragging
        * state and he is moving his finger.  We want to intercept this
        * motion.
        */
        final int action = ev.getAction();
     // 如果已经在拖动了,说明已经在滑动了,直接返回 true
        if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
            return true;
        }

        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_MOVE: {
                /*
                 * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
                 * whether the user has moved far enough from his original down touch.
                 */

                /*
                * Locally do absolute value. mLastMotionY is set to the y value
                * of the down event.
                */
                final int activePointerId = mActivePointerId;
                if (activePointerId == INVALID_POINTER) {
                    // If we don't have a valid id, the touch down wasn't on content. 不是一个有效的id
                    break;
                }

                final int pointerIndex = ev.findPointerIndex(activePointerId);
                if (pointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + activePointerId
                            + " in onInterceptTouchEvent");
                    break;
                }

                final int y = (int) ev.getY(pointerIndex);
          // 计算垂直方向上滑动的距离
                final int yDiff = Math.abs(y - mLastMotionY);
          // 确定可以产生滚动了
                if (yDiff > mTouchSlop
                        && (getNestedScrollAxes() & ViewCompat.SCROLL_AXIS_VERTICAL) == 0) {
                    mIsBeingDragged = true;
                    mLastMotionY = y;
                    initVelocityTrackerIfNotExists();
             // 可以获取滑动速率
                    mVelocityTracker.addMovement(ev);
                    mNestedYOffset = 0;
                    final ViewParent parent = getParent();
                    if (parent != null) {
               // 让父 view 不要拦截,这里应该是为了保险起见,因为既然已经走进来了,只要你返回 true,父 view 就不会拦截了。
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                }
                break;
            }

            case MotionEvent.ACTION_DOWN: {
                final int y = (int) ev.getY();
          // 如果点击的范围不在子 view 上,直接break,比如自己设置了很大的 margin,此时用户点击这里,这个范围理论上是不参与滑动的
                if (!inChild((int) ev.getX(), y)) {
                    mIsBeingDragged = false;
                    recycleVelocityTracker();
                    break;
                }

                /*
                 * Remember location of down touch.
                 * ACTION_DOWN always refers to pointer index 0.
                 */
                mLastMotionY = y;
                mActivePointerId = ev.getPointerId(0);
          // 在收到 DOWN 事件的时候,做一些初始化的工作
                initOrResetVelocityTracker();
                mVelocityTracker.addMovement(ev);
                /*
                 * If being flinged and user touches the screen, initiate drag;
                 * otherwise don't. mScroller.isFinished should be false when
                 * being flinged. We need to call computeScrollOffset() first so that
                 * isFinished() is correct.
                */
                mScroller.computeScrollOffset();
          // 如果此时正在fling, isFinished 会返回 flase
                mIsBeingDragged = !mScroller.isFinished();
          // 开始滑动
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                /* Release the drag */
                mIsBeingDragged = false;
                mActivePointerId = INVALID_POINTER;
                recycleVelocityTracker();
                if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0, getScrollRange())) {
                    ViewCompat.postInvalidateOnAnimation(this);
                }
          // 手抬起后,停止滑动
                stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;
        }

        /*
        * The only time we want to intercept motion events is if we are in the
        * drag mode.
        */
        return mIsBeingDragged;
    }

onInterceptTouchEvent 事件就是做一件事,决定事件是不是要继续交给自己的 onTouchEvent 处理。这里需要注意的一点是,如果子 view 在 dispatchTouchEvent 中调用了:

parent.requestDisallowInterceptTouchEvent(true)

那么,其实就不会再调用 onInterceptTouchEvent 方法。也就是说上面的逻辑就不会走了。但是可以发现,down 事件,一般是不会拦截的。但是如果正在 fling,此时就会返回 true,直接把事件全部拦截。

那看下 RecyclerView 的  dispatchTouchEvent 是父类的,没啥好分析的。而且它的 onInterceptTouchEvent 也是做了一些初始化的一些工作,和 NestedScrollView 一样没啥可说的。

onTouchEvent

再说 NestedScrollView 的 onTouchEvent。

对于 onTouchEvent 得分两类进行讨论,如果其子 view 不是 ViewGroup ,且是不可点击的,就会把事件直接交给 NestedScrollView 来处理。

但是如果点击的子 view 是 RecyclerView 的 ViewGroup 。当 down 事件来的时候,ViewGroup 的子 view 没有处理,那么就会交给 ViewGroup 来处理,你会发现ViewGroup 的 onTouchEvent 是默认返回 true 的。也就是说事件都是由  RecyclerView 来处理的。

这时候来看下 NestedScrollView 的 onTouchEvent 代码:

 public boolean onTouchEvent(MotionEvent ev) {
        initVelocityTrackerIfNotExists();

        MotionEvent vtev = MotionEvent.obtain(ev);

        final int actionMasked = ev.getActionMasked();

        if (actionMasked == MotionEvent.ACTION_DOWN) {
            mNestedYOffset = 0;
        }
        vtev.offsetLocation(0, mNestedYOffset);

        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
          // 需要有一个子类才可以进行滑动
                if (getChildCount() == 0) {
                    return false;
                }
          // 前面提到如果用户在 fling 的时候,触碰,此时是直接拦截返回 true,自己来处理事件。
                if ((mIsBeingDragged = !mScroller.isFinished())) {
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
                }

                /*
                 * If being flinged and user touches, stop the fling. isFinished
                 * will be false if being flinged.处理结果就是停止 fling
                 */
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                // Remember where the motion event started
                mLastMotionY = (int) ev.getY();
                mActivePointerId = ev.getPointerId(0);
         // 寻找嵌套父View,告诉它准备在垂直方向上进行 TOUCH 类型的滑动 
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                if (activePointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                    break;
                }

                final int y = (int) ev.getY(activePointerIndex);
                int deltaY = mLastMotionY - y;
          // 滑动前先把移动距离告诉嵌套父View,看看它要不要消耗,返回 true 代表消耗了部分距离
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    deltaY -= mScrollConsumed[1];
                    vtev.offsetLocation(0, mScrollOffset[1]);
                    mNestedYOffset += mScrollOffset[1];
                }
          // 滑动距离大于最大最小触发距离
                if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
                    final ViewParent parent = getParent();
                    if (parent != null) {
                        parent.requestDisallowInterceptTouchEvent(true);
                    }
            // 触发滑动
                    mIsBeingDragged = true;
                    if (deltaY > 0) {
                        deltaY -= mTouchSlop;
                    } else {
                        deltaY += mTouchSlop;
                    }
                }
                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    mLastMotionY = y - mScrollOffset[1];

                    final int oldY = getScrollY();
                    final int range = getScrollRange();
                    final int overscrollMode = getOverScrollMode();
                    boolean canOverscroll = overscrollMode == View.OVER_SCROLL_ALWAYS
                            || (overscrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

                    // Calling overScrollByCompat will call onOverScrolled, which
                    // calls onScrollChanged if applicable.
            // 该方法会触发自身内容的滚动
                    if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
                            0, true) && !hasNestedScrollingParent(ViewCompat.TYPE_TOUCH)) {
                        // Break our velocity if we hit a scroll barrier.
                        mVelocityTracker.clear();
                    }

                    final int scrolledDeltaY = getScrollY() - oldY;
                    final int unconsumedY = deltaY - scrolledDeltaY;
            // 通知嵌套的父 View 我已经处理完滚动了,该你来处理了
                    if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                            ViewCompat.TYPE_TOUCH)) {
              // 如果嵌套父View 消耗了滑动,那么需要更新
                        mLastMotionY -= mScrollOffset[1];
                        vtev.offsetLocation(0, mScrollOffset[1]);
                        mNestedYOffset += mScrollOffset[1];
                    } else if (canOverscroll) {
                        ensureGlows();
                        final int pulledToY = oldY + deltaY;
               // 触发边缘的阴影效果
                        if (pulledToY < 0) {
                            EdgeEffectCompat.onPull(mEdgeGlowTop, (float) deltaY / getHeight(),
                                    ev.getX(activePointerIndex) / getWidth());
                            if (!mEdgeGlowBottom.isFinished()) {
                                mEdgeGlowBottom.onRelease();
                            }
                        } else if (pulledToY > range) {
                            EdgeEffectCompat.onPull(mEdgeGlowBottom, (float) deltaY / getHeight(),
                                    1.f - ev.getX(activePointerIndex)
                                            / getWidth());
                            if (!mEdgeGlowTop.isFinished()) {
                                mEdgeGlowTop.onRelease();
                            }
                        }
                        if (mEdgeGlowTop != null
                                && (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
                            ViewCompat.postInvalidateOnAnimation(this);
                        }
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                final VelocityTracker velocityTracker = mVelocityTracker;
                velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
          // 计算滑动速率
                int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
          // 大于最小的设定的速率,触发fling
                if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                    flingWithNestedDispatch(-initialVelocity);
                } else if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                        getScrollRange())) {
                    ViewCompat.postInvalidateOnAnimation(this);
                }
                mActivePointerId = INVALID_POINTER;
                endDrag();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (mIsBeingDragged && getChildCount() > 0) {
                    if (mScroller.springBack(getScrollX(), getScrollY(), 0, 0, 0,
                            getScrollRange())) {
                        ViewCompat.postInvalidateOnAnimation(this);
                    }
                }
                mActivePointerId = INVALID_POINTER;
                endDrag();
                break;
            case MotionEvent.ACTION_POINTER_DOWN: {
                final int index = ev.getActionIndex();
                mLastMotionY = (int) ev.getY(index);
                mActivePointerId = ev.getPointerId(index);
                break;
            }
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
                break;
        }

        if (mVelocityTracker != null) {
            mVelocityTracker.addMovement(vtev);
        }
        vtev.recycle();
        return true;
    }

ACTION_DOWN

先看 down 事件,如果处于 fling 期间,那么直接停止 fling, 接着会调用 startNestedScroll,会让 NestedScrollView 作为子 view 去 通知嵌套父 view,那么就需要找到有没有可以嵌套滑动的父 view 。

    public boolean startNestedScroll(int axes, int type) {
        // 交给 mChildHelper 代理来处理相关逻辑
        return mChildHelper.startNestedScroll(axes, type);
    }


    public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        // 找到嵌套父 view 了,就直接返回
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        // 是否支持嵌套滚动
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {  // while 循环,将支持嵌套滑动的父 View 找出来。
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    // 把父 view 设置进去
                    setNestedScrollingParentForType(type, p);
                    // 找到后,通过该方法可以做一些初始化操作
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }            

可以看到,这时候主要就是为了找到嵌套父 view。当 ViewParentCompat.onStartNestedScroll 返回 true,就表示已经找到嵌套滚动的父 View 了 。下面来看下这个方法的具体逻辑:

    // ViewParentCompat  
    public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
            int nestedScrollAxes, int type) {
        if (parent instanceof NestedScrollingParent2) {
            // First try the NestedScrollingParent2 API
            return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {
            // Else if the type is the default (touch), try the NestedScrollingParent API
            if (Build.VERSION.SDK_INT >= 21) {
                try {
                    return parent.onStartNestedScroll(child, target, nestedScrollAxes);
                } catch (AbstractMethodError e) {
                    Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                            + "method onStartNestedScroll", e);
                }
            } else if (parent instanceof NestedScrollingParent) {
                return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                        nestedScrollAxes);
            }
        }
        return false;
    }

这里其实没啥好分析,就是告诉父类当前是什么类型的滚动,以及滚动方向。其实这里可以直接看下 NestedScrollView 的 onStartNestedScroll 的逻辑。

//  NestedScrollView
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
            int type) {
     // 确保触发的是垂直方向的滚动
        return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }

当确定了嵌套父 View 以后,又会调用 父 view 的  onNestedScrollAccepted 方法 ,在这里可以做一些准备工作和配置。下面我们看到的 是 Ns 里面的方法,注意不是父 view 的,只是当作参考。

public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
    mParentHelper.onNestedScrollAccepted(child, target, axes, type);
   // 这里 Ns 作为子 view 调用 该方法去寻找嵌套父 view。注意这个方法会被调用是 NS 作为父 view 收到的。这样就
    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, type);
}

到这里,down 的作用就讲完了。

ACTION_MOVE

首先是会调用 dispatchNestedPreScroll,讲当前的滑动距离告诉嵌套父 View。

  public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
            int type) {
     // Ns 作为子 view 去通知父View
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    } 

下面看下 mChildHelper 的代码逻辑:

    /**
     * Dispatch one step of a nested pre-scrolling operation to the current nested scrolling parent.
     *
     * <p>This is a delegate method. Call it from your {@link android.view.View View} subclass
     * method/{@link androidx.core.view.NestedScrollingChild2} interface method with the same
     * signature to implement the standard policy.</p>
     *
     * @return true if the parent consumed any of the nested scroll
     */
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
       // 获取之前找到的嵌套滚动的父 View
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }
       // 滑动距离肯定不为0 才有意义
            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;
          // 调用嵌套父 View 的对应的回调
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

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

这里主要是将滑动距离告诉 父 view,有消耗就会返回 true 。

    // ViewParentCompat
    public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
            int[] consumed) {
        onNestedPreScroll(parent, target, dx, dy, consumed, ViewCompat.TYPE_TOUCH);
    }

其实下面的 onNestedPreScroll 跟前面的 onStartNestedScroll 逻辑很像,就是层层传递。

    public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
            int[] consumed, int type) {
        if (parent instanceof NestedScrollingParent2) {
            // First try the NestedScrollingParent2 API
            ((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, type);
        } else if (type == ViewCompat.TYPE_TOUCH) {
            // Else if the type is the default (touch), try the NestedScrollingParent API
            if (Build.VERSION.SDK_INT >= 21) {
                try {
                    parent.onNestedPreScroll(target, dx, dy, consumed);
                } catch (AbstractMethodError e) {
                    Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                            + "method onNestedPreScroll", e);
                }
            } else if (parent instanceof NestedScrollingParent) {
                ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
            }
        }
    }

下面为了方便,没法查看 NS 的嵌套父 View 的逻辑。直接看 Ns 中对应的方法。

    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
            int type) {
     // 最终也是 Ns 再传给其嵌套父 View 
        dispatchNestedPreScroll(dx, dy, consumed, null, type);
    }

传递完了之后,就会调用  overScrollByCompat 来实现滚动。

    boolean overScrollByCompat(int deltaX, int deltaY,
            int scrollX, int scrollY,
            int scrollRangeX, int scrollRangeY,
            int maxOverScrollX, int maxOverScrollY,
            boolean isTouchEvent) {
        final int overScrollMode = getOverScrollMode();
        final boolean canScrollHorizontal =
                computeHorizontalScrollRange() > computeHorizontalScrollExtent();
        final boolean canScrollVertical =
                computeVerticalScrollRange() > computeVerticalScrollExtent();
        final boolean overScrollHorizontal = overScrollMode == View.OVER_SCROLL_ALWAYS
                || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollHorizontal);
        final boolean overScrollVertical = overScrollMode == View.OVER_SCROLL_ALWAYS
                || (overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS && canScrollVertical);

        int newScrollX = scrollX + deltaX;
        if (!overScrollHorizontal) {
            maxOverScrollX = 0;
        }

        int newScrollY = scrollY + deltaY;
        if (!overScrollVertical) {
            maxOverScrollY = 0;
        }

        // Clamp values if at the limits and record
        final int left = -maxOverScrollX;
        final int right = maxOverScrollX + scrollRangeX;
        final int top = -maxOverScrollY;
        final int bottom = maxOverScrollY + scrollRangeY;

        boolean clampedX = false;
        if (newScrollX > right) {
            newScrollX = right;
            clampedX = true;
        } else if (newScrollX < left) {
            newScrollX = left;
            clampedX = true;
        }

        boolean clampedY = false;
        if (newScrollY > bottom) {
            newScrollY = bottom;
            clampedY = true;
        } else if (newScrollY < top) {
            newScrollY = top;
            clampedY = true;
        }

        if (clampedY && !hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
            mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
        }
     
        onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);

        return clampedX || clampedY;
    }

整块逻辑其实没啥好说的,然后主要是看 onOverScrolled 这个方法:

   protected void onOverScrolled(int scrollX, int scrollY,
            boolean clampedX, boolean clampedY) {
        super.scrollTo(scrollX, scrollY);
    }

最终是调用 scrollTo 方法来实现了滚动。

当滚动完了后,会调用 dispatchNestedScroll 告诉父 view 当前还剩多少没消耗,如果是 0,那么就不会上传,如果没消耗完,就会传给父 View 。

如果是子 View 传给 NS 的,是会通过 scrollBy 来进行消耗的,然后继续向上层传递。

    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
            int dyUnconsumed, int type) {
        final int oldScrollY = getScrollY();
        scrollBy(0, dyUnconsumed);
        final int myConsumed = getScrollY() - oldScrollY;
        final int myUnconsumed = dyUnconsumed - myConsumed;
        dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null,
                type);
    }

假设当前已经滑动到顶部了,此时继续滑动的话,就会触发边缘的阴影效果。

ACTION_UP

当用户手指离开后,如果滑动速率超过最小的滑动速率,就会调用 flingWithNestedDispatch(-initialVelocity) ,下面来看看这个方法的具体逻辑:

    private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0)
                && (scrollY < getScrollRange() || velocityY < 0);
     // fling 前问问父View 要不要 fling, 一般是返回 false
        if (!dispatchNestedPreFling(0, velocityY)) {
       // 这里主要是告诉父类打算自己消耗了
            dispatchNestedFling(0, velocityY, canFling);
       // 自己处理
            fling(velocityY);
        }
    }

下面继续看 fling 的实现。

    public void fling(int velocityY) {
        if (getChildCount() > 0) {

            mScroller.fling(getScrollX(), getScrollY(), // start
                    0, velocityY, // velocities
                    0, 0, // x
                    Integer.MIN_VALUE, Integer.MAX_VALUE, // y
                    0, 0); // overscroll
            runAnimatedScroll(true);
        }
    }

    private void runAnimatedScroll(boolean participateInNestedScrolling) {
        if (participateInNestedScrolling) {
            // fling 其实也是一种滚动,只不过是非接触的
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
        } else {
            stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
        }
        mLastScrollerY = getScrollY();
        ViewCompat.postInvalidateOnAnimation(this);
    }

最终会触发重绘操作,重绘过程中会调用 computeScroll,下面看下其内部的代码逻辑。

    @Override
    public void computeScroll() {

        if (mScroller.isFinished()) {
            return;
        }

        mScroller.computeScrollOffset();
        final int y = mScroller.getCurrY();
        int unconsumed = y - mLastScrollerY;
        mLastScrollerY = y;

        // Nested Scrolling Pre Pass
        mScrollConsumed[1] = 0;
     // 滚动的时候,依然会把当前的未消耗的滚动距离传给嵌套父View
        dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
                ViewCompat.TYPE_NON_TOUCH);
        unconsumed -= mScrollConsumed[1];

        final int range = getScrollRange();

        if (unconsumed != 0) {
            // Internal Scroll
            final int oldScrollY = getScrollY();
       // 自己消耗
            overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
            final int scrolledByMe = getScrollY() - oldScrollY;
            unconsumed -= scrolledByMe;

            // Nested Scrolling Post Pass
            mScrollConsumed[1] = 0;
        // 继续上传给父View
            dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
                    ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
            unconsumed -= mScrollConsumed[1];
        }
     // 如果到这里有未消耗的,说明已经滚动到边缘了
        if (unconsumed != 0) {
            final int mode = getOverScrollMode();
            final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
                    || (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
            if (canOverscroll) {
                ensureGlows();
                if (unconsumed < 0) {
                    if (mEdgeGlowTop.isFinished()) {
                        mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                } else {
                    if (mEdgeGlowBottom.isFinished()) {
                        mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
                    }
                }
            }
       // 停止滚动  
            abortAnimatedScroll();
        }
     // 如果此时滚动还未结束,并且当前的滑动距离都被消耗了,那么继续刷新滚动,直到停止为止
        if (!mScroller.isFinished()) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

到这里,关于 Ns 的嵌套滑动就讲完了。希望大家能够对嵌套滑动有个理解。

阅读 Ns 的源码,可以让你更好的理解嵌套滑动,以及事件分发的逻辑。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK