PHP.DHN透过 NestedScrollView 源码解析嵌套滑动原理-附教程

Posted fjj-1515

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了PHP.DHN透过 NestedScrollView 源码解析嵌套滑动原理-附教程相关的知识,希望对你有一定的参考价值。

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 之间存在相互调用,遵循下面的调用关系:

技术图片

上图可以这么理解:

  • 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,否则会报错。

技术图片 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>
技术图片

效果:

技术图片

可以发现这个没有铺满全屏,可是 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); } }

以上是关于PHP.DHN透过 NestedScrollView 源码解析嵌套滑动原理-附教程的主要内容,如果未能解决你的问题,请参考以下文章

使用 Fragment 内的方法更改 TextView 值 (NullPointerException)

透过 MacOS 安装 Raspbian

透过DOM操作WebBrowser内的控件

NestedScrollView 中的 GridLayout ,为啥我的列会超出视图空间?

Linux 0.11-透过 fork 来看进程的内存规划-28

Linux 0.11-透过 fork 来看进程的内存规划-28