Material Design系列教程(5) - NestedScrollView

简介

NestedScrollView支持嵌套滑动的 ScrollView

因此,我们可以简单的把 NestedScrollView 类比为 ScrollView,其作用就是作为控件父布局,从而具备(嵌套)滑动功能。

NestedScrollViewScrollView 的区别就在于 NestedScrollView 支持 嵌套滑动,无论是作为父控件还是子控件,嵌套滑动都支持,且默认开启。

因此,在一些需要支持嵌套滑动的情景中,比如一个 ScrollView 内部包裹一个 RecyclerView,那么就会产生滑动冲突,这个问题就需要你自己去解决。而如果使用 NestedScrollView 包裹 RecyclerView,嵌套滑动天然支持,你无需做什么就可以实现前面想要实现的功能了。

举个例子:

我们通常为RecyclerView增加一个 Header 和 Footer 的方法是通过定义不同的 viewType来区分的,而如果使用 NestedScrollView,我们完全可以把RecyclerView当成一个单独的控件,然后在其上面增加一个控件作为 Header,在其下面增加一个控件作为 Footer。

具体布局如下所示:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:descendantFocusability="blocksDescendants"
        android:orientation="vertical">

        <!-- This is the Header -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:background="#888888"
            android:gravity="center"
            android:text="Header"
            android:textColor="#0000FF"
            android:textSize="30sp" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/rc"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <!-- This is the Footer -->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:background="#888888"
            android:gravity="center"
            android:text="Footer"
            android:textColor="#FF0000"
            android:textSize="30sp" />

    </LinearLayout>

</android.support.v4.widget.NestedScrollView>

注: NestedScrollViewScrollView 一样,内部只能容纳一个子控件。

效果如下所示:

NestedScrollView: header_footer

ps: 虽然 NestedScrollView 内嵌RecyclerView和其他控件可以实现 Header 和 Footer,但还是不推荐上面这种做法(建议还是直接使用RecyclerView自己添加 Header 和 Footer),因为虽然 NestedScrollView 支持嵌套滑动,但是在实际应用中,嵌套滑动可能会带来其他的一些奇奇怪怪的副作用,Google 也推荐我们能不使用嵌套滑动就尽量不要使用。

如果想知道 NestedScrollView 嵌套其他控件可能带来的问题,可以查看:NestedScrollView、RecycleView、ViewPager 等布局方面的常见问题汇总,及解决

嵌套滑动机制 简析

我们知道,Android 的事件分发机制中,只要有一个控件消费了事件,其他控件就没办法再接收到这个事件了。因此,当有嵌套滑动场景时,我们都需要自己手动解决事件冲突。而在 Android 5.0 Lollipop 之后,Google 官方通过 嵌套滑动机制 解决了传统 Android 事件分发无法共享事件这个问题。

嵌套滑动机制 的基本原理可以认为是事件共享,即当子控件接收到滑动事件,准备要滑动时,会先通知父控件(startNestedScroll);然后在滑动之前,会先询问父控件是否要滑动(dispatchNestedPreScroll);如果父控件响应该事件进行了滑动,那么就会通知子控件它具体消耗了多少滑动距离;然后交由子控件处理剩余的滑动距离;最后子控件滑动结束后,如果滑动距离还有剩余,就会再问一下父控件是否需要在继续滑动剩下的距离(dispatchNestedScroll)...

上面其实就是 嵌套滑动机制 的工作原理,那么如果想让我们自定义的View或者ViewGroup实现嵌套滑动功能,应该怎样做呢?

其实,在 Android 5.0 之后,系统自带的ViewViewGroup都增加了 嵌套滑动机制 相关的方法了(但是默认不会被调用,因此默认不具备嵌套滑动功能),所以如果在 Android 5.0 及之后的平台上,自定义View只要覆写相应的 嵌套滑动机制 相关方法即可;但是为了提供低版本兼容性,Google 官方还提供了两个接口,分别作为 嵌套滑动机制 父控件接口和子控件接口:

前面我们说过 NestedScrollView 无论是作为父控件还是子控件都支持嵌套滑动,就是因为它同时实现了 NestedScrollingParentNestedScrollingChild。文档如下所示:

NestedScrollView

这里看到 NestedScrollView 实现的是NestedScrollingChild2 接口,这是因为在 Android v25.x 以前,嵌套滚动 API 存在缺陷:当用户触发 ACTION_UP 事件时,如果 view 存在的惯性较大(fling 快速划动),它将调用 dispatchNestedPreFling 让 parent 继续消费 velocity。但是如果 parent 返回 false,不进行消费,那么 view 将开始滑动并立即调用 dispatchNestedFling,然后立即调用stopNestedScroll 来将嵌套滚动标记为结束,即使 view 自己实际上还处于滑动(fling) 中。

这里的问题就在于当 ACTION_UP 事件发生后,parent 对当前剩余的滑动不感兴趣,因此滑动事件给到 view,view 则可以进行滑动。这样就存在一种场景,即此时 view 滑动到顶部/底部时,剩余速度还是很大,这里我们正常的思维就是可以把这部分剩余速度给到 parent 去响应,而由于在 ACTION_UP 后,嵌套滑动机制已经结束了,所以这些事件再也无法传递给parent,剩余的速度会被丢失。

为了修复上述这个问题,Google 在支持库的 26.0.0-beta2 版本中,发布了一些对嵌套滚动 API 的改进:

其实新 API 的修复方法就是在现有的方法上添加了一个新的参数 type。
由 type 参数就可以知道当前是什么类型的输入在驱动滑动(scroll)事件,目前有两种选项:

  • ViewCompat. TYPE_TOUCH:正常的屏幕触控驱动事件
  • ViewCompat. TYPE_NON_TOUCH:非手指触碰手势输入类型,通常是手指离开屏幕后的惯性滑动事件

参照我们上面的场景,在使用新嵌套滑动 API 后,此时的运行情景如下:

  1. 手指触摸,滑动与之前情景一致,但是这次传入了 TYPE_TOUCH 参数:startNestedScroll(TYPE_TOUCH)

  2. 手指离开屏幕,触发 ACTION_UP 事件,场景与之前一致,stopNestedScroll(TYPE_TOUCH) 被调用同时 touch 嵌套滚动结束。

  3. view 开始 fling。此时将开始新的一轮嵌套滚动,不过这次是 TYPE_NON_TOUCH 类型,从startNestedScroll(TYPE_NON_TOUCH),到dispatchNestedPreScroll(TYPE_NON_TOUCH) + dispatchNestedScroll(TYPE_NON_TOUCH), 最后是 stopNestedScroll(TYPE_NON_TOUCH)。这次所有事情都是 view 的 fling (通常是一个 Scroller)驱动的,而不是触摸事件。

所以,其实新 API 的修复方法就是在用户手指离开屏幕后,为惯性滑动开启了新的一轮嵌套滑动事件,且该事件由参数 type=TYPE_NON_TOUCH 进行标识。

更多详细信息,请参考:

那么下面我们就按新的滑动嵌套 API 来查看下其具体详情把:

先来看下嵌套滑动父控件接口具体 API:

NestedScrollingParent

图中画黑线的就是旧的,有缺陷(未添加参数 type )的那些方法。由于嵌套滑动是由子View发起的,所以父控件很多方法都是作为回调的,因此很多是没有返回值的,唯一有返回值的就是onStartNestedScroll:表示该父控件是否要接收该滑动事件;onNestedPreFlingonNestedFling,表示是否消费 fling 事件。

那么下面就对几个相对重要的方法进行解读把:

1)boolean onStartNestedScroll (View child, View target, int axes, int type):当子 view (直接或间接)调用startNestedScroll(View, int)时,会回调父控件该方法。

参数 解释
child 包裹 target 的父布局的直接子View(该直接子View不一定是发生滑动嵌套的view)
target 触发嵌套滑动的 view
axes 表示滚动的方向:
ViewCompat.SCROLL_AXIS_VERTICAL(垂直方向滚动)
ViewCompat.SCROLL_AXIS_HORIZONTAL(水平方向滚动)
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH
返回值 解释
boolean true:表示父控件接受该嵌套滑动事件,后续嵌套滑动事件就会通知到该父控件

2)void onNestedScrollAccepted (View child, View target, int axes, int type):当onStartNestedScroll返回true时,该方法被回调, 父控件针可以在该方法中对嵌套滑动做一些前期工作。覆写该方法时,记得要调用父类实现:super.onNestedScrollAccepted,如果存在父类的话。

该方法参数释意同onStartNestedScroll

3)void onNestedPreScroll (View target, int dx, int dy, int[] consumed, int type):在子View消费滑动事件前,优先响应滑动操作,消费部分或全部滑动距离。

参数 解释
target 触发嵌套滑动的 view
dx 表示 view 本次 x 方向的滚动的总距离,单位:像素
dy 表示 view 本次 y 方向的滚动的总距离,单位:像素
consumed 输出:表示父布局消费的水平和垂直距离。
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH

4)void onNestedScroll (View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type):接收子View处理完滑动后的滑动距离信息, 在这里父控件可以选择是否处理剩余的滑动距离。如果想要该方法得到回调,先前的onStartNestedScroll(View, View, int, int)必须返回true

参数 解释
dxConsumed 表示 view 消费了 x 方向的距离
dyConsumed 表示 view 消费了 y 方向的距离
dxUnconsumed 表示 view 剩余未消费 x 方向距离
dyUnconsumed 表示 view 剩余未消费 y 方向距离
offsetInWindow 可选参数:如果非空,则表示在局部坐标系中,该 view 在滑动事件后与滑动前的偏移量,View实现可以根据该值调整期望的输入坐标追踪
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH

先来看下嵌套滑动子控件接口具体 API:

NestedScrollingChild

1)boolean startNestedScroll (int axes, int type):在子View准备滑动时,会调用该方法;在屏幕触摸事件中,相当于在 ACTION_DOWN 中调用该方法;在触碰滑动阶段,内嵌滑动会自动停止,就如同调用了requestDisallowInterceptTouchEvent(boolean);在自编程的滑动中,使用者必须自己显示调用stopNestedScroll(int)来通知嵌套活动终止。该方法的主要作用其实就是用来找寻一个支持嵌套滑动的父控件(由内向外找,视图层级的View都有可能成为嵌套滑动父控件,只要其回调函数onStartNestedScroll返回true)。调用该方法后,会回调父控件onStartNestedScroll方法。

参数 解释
axes 表示滚动的方向:
ViewCompat.SCROLL_AXIS_VERTICAL(垂直方向滚动)
ViewCompat.SCROLL_AXIS_HORIZONTAL(水平方向滚动)
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH
返回值 解释
boolean true:如果找到了父控件(实现了 NestedScrollingParent2NestedScrollingParent),并且当前 view 使能了嵌套滑动功能(setNestedScrollingEnabled(true)

2)boolean dispatchNestedPreScroll (int dx, int dy, int[] consumed, int[] offsetInWindow, int type):在滑动之前,调用该方法,具体为在屏幕触摸事件中,每一次接收到 ACTION_MOVE 事件时调用。该方法为父控件提供了在子View消费滑动事件前,消费部分或全部滑动事件的机会。调用该方法后,会回调父控件的onNestedPreScroll方法。

参数 解释
dx 表示 view 本次 x 方向的滚动的总距离,单位:像素
dy 表示 view 本次 y 方向的滚动的总距离,单位:像素
consumed 输出:表示父布局消费的距离。如果非空,consumed[0] 表示 x 方向父布局消费的距离,consumed[1] 表示 y 方向父布局消费的距离
offsetInWindow 可选参数:如果非空,则表示在局部坐标系中,该 view 在滑动事件后与滑动前的偏移量,View实现可以根据该值调整期望的输入坐标追踪
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH
返回值 解释
boolean true:表示父控件消费了部分或全部滑动事件

3) boolean dispatchNestedScroll (int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow, int type):处于拖动状态时,会调用该方法,回调父控件的onNestedScroll方法,传递当前 view 滑动距离详情给到父控件。

参数 解释
dxConsumed 表示 view 消费了 x 方向的距离
dyConsumed 表示 view 消费了 y 方向的距离
dxUnconsumed 表示 view 剩余未消费 x 方向距离
dyUnconsumed 表示 view 剩余未消费 y 方向距离
offsetInWindow 可选参数:如果非空,则表示在局部坐标系中,该 view 在滑动事件后与滑动前的偏移量,View实现可以根据该值调整期望的输入坐标追踪
type 触发滑动事件的类型:其值有
ViewCompat. TYPE_TOUCH
ViewCompat. TYPE_NON_TOUCH
返回值 解释
:--: :--
boolean true:如果事件已成功分发;false:分发失败

4)void stopNestedScroll (int type):当嵌套滑动停止时,调用该方法,会回调父控件的onStopNestedScroll,通知父控件嵌套滑动结束。该方法调用时机为 ACTION_UP 或者 ACTION_CANCEL 事件发生时,且没有惯性滑动(fling)事件。

上面介绍的就是 NestedScrollingChild2 主要延伸的新方法,下面再介绍下 NestedScrollingChild 的一些比较重要的方法:

5)boolean dispatchNestedPreFling (float velocityX, float velocityY):在 view 消费 fling 事件前,将该事件分发给父控件,让父控件决定是否消费掉整个 fling 事件。该事件发生时机:在 ACTION_UP 事件发生时,并且存在惯性滚动。

参数 解释
velocityX 水平惯性滑动速率,单位:像素/秒
velocityY 垂直惯性滑动速率,单位:像素/秒
返回值 解释
boolean true:父控件消费该 fling 事件

6)boolean dispatchNestedFling (float velocityX, float velocityY, boolean consumed):分发 fling 事件给父控件。

参数 解释
velocityX 水平惯性滑动速率,单位:像素/秒
velocityY 垂直惯性滑动速率,单位:像素/秒
consumed true:子控件消费了该 fling 事件
返回值 解释
boolean true:父控件消费该 fling 事件或者对该事件做出了反应

NestedScrollView 源码解析

上面我们讲解了 NestedScrollView 的特性和 嵌套滑动机制 的原理及其相关 API,由于 NestedScrollView 同时支持父控件/子控件嵌套滑动功能,那么接下来我们就来分析下 NestedScrollView 源码,让我们能更好地了解 嵌套滑动机制 的实现原理(主要分析作为子控件的嵌套滑动机制实现原理):

首先,就像我们前面讲的,嵌套滑动机制 的核心原理就是事件共享,所以其实它就是在 Android 原有的事件分发机制上进行修改,使得同一事件可以在父/子控件间共享。

那我们就直接找下 NestedScrollView 的事件函数onTouchEvent进行查看:

//NestedScrollView.java
    public boolean onTouchEvent(MotionEvent ev) {
        ...
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                ...
                //子 view 接收到滑动事件,通知父控件
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                ...
                break;
            case MotionEvent.ACTION_UP:
                ...
                break;
            case MotionEvent.ACTION_CANCEL:
                ...
                break;
        }
        ...
        return true;
    }

NestedScrollView.onTouchEvent函数中,我们可以看到:
ACTION_DOWN 事件中,NestedScrollView 只调用了startNestedScroll,那我们继续追踪来看下startNestedScroll的源码实现:

//NestedScrollView.java
    private final NestedScrollingChildHelper mChildHelper;
    public NestedScrollView(@NonNull Context context, @Nullable AttributeSet attrs,
            int defStyleAttr) {
        ...
        //NestedScrollingParent 辅助类
        mParentHelper = new NestedScrollingParentHelper(this);
       //NestedScrollingChild 辅助类
        mChildHelper = new NestedScrollingChildHelper(this);

        // 默认开启嵌套滑动功能
        // ...because why else would you be using this widget?
        setNestedScrollingEnabled(true);

        ViewCompat.setAccessibilityDelegate(this, ACCESSIBILITY_DELEGATE);
    }

    @Override
    public boolean startNestedScroll(int axes, int type) {
        return mChildHelper.startNestedScroll(axes, type);
    }

NestedScrollView.startNestedScroll借助辅助类NestedScrollingChildHelper代理方法调用(对于 NestedScrollingChild ,系统提供了一个NestedScrollingChildHelper来帮助控件实现嵌套滑动功能;同理,对于 NestedScrollingParent,系统同样提供了一个辅助类:NestedScrollingParentHelper。借助这两个辅助类,可以更方便地让我们实现嵌套滑动功能)。

那我们在继续追踪mChildHelper.startNestedScroll方法实现:

// NestedScrollingChildHelper.java
     public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
        if (hasNestedScrollingParent(type)) {
            // Already in progress
            return true;
        }
        // 首先子 view 要先使能嵌套滑动机制
        if (isNestedScrollingEnabled()) {
            ViewParent p = mView.getParent();
            View child = mView;
            while (p != null) {
                // 遍历子 view 视图层级,找到父控件为NestedScrollingParent2或NestedScrollingParent,并且其onStartNestedScroll返回true,表示接受该嵌套滑动事件
                if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                    setNestedScrollingParentForType(type, p);
                    // 找到父控件后,立即调用onNestedScrollAccepted
                    ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                    return true;
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

看到这里,终于理清了NestedScrollView.startNestedScroll的流程,其实startNestedScroll就是在子 view 视图层级一级一级往上找,直到找到一个支持嵌套滑动的父控件(实现了NestedScrollingParent2NestedScrollingParent,并且onStartNestedScroll返回true);找到后,就会立即回调父控件的onNestedScrollAccepted方法。

至此,ACTION_DOWN 事件的处理就结束了。

接下来看下 NestedScrollView 对于 ACTION_MOVE 事件的处理:

//NestedScrollView.java
    public boolean onTouchEvent(MotionEvent ev) {
        ...
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                ...
                break;
            }
            case MotionEvent.ACTION_MOVE:
                ...
                // deltaY:垂直移动的距离,dy=上一次y值 - 当前y值
                int deltaY = mLastMotionY - y;
                //子 view 准备滑动,通知父控件
                if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
                        ViewCompat.TYPE_TOUCH)) {
                    // 父控件消费了mScrollConsumed[1],子 view 还剩下 deltaY 距离可以消费
                    deltaY -= mScrollConsumed[1];
                    vtev.offsetLocation(0, mScrollOffset[1]);
                    mNestedYOffset += mScrollOffset[1];
                }
                ...
                // 在拖动状态下
                if (mIsBeingDragged) {
                    ...
                    // 子 view 消费滑动事件后,将消费距离详情通知父控件
                    if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
                            ViewCompat.TYPE_TOUCH)) {
                        mLastMotionY -= mScrollOffset[1];
                        vtev.offsetLocation(0, mScrollOffset[1]);
                        mNestedYOffset += mScrollOffset[1];
                    } 
                    ...
                }
                break;
            case MotionEvent.ACTION_UP:
                ...
                break;
            case MotionEvent.ACTION_CANCEL:
                ...
                break;
            ...
        }
        ...
        return true;
    }

所以,NestedScrollViewACTION_MOVE 事件里,主要做了两件事:在每次滑动前调用dispatchNestedPreScroll和在拖动状态下分发dispatchNestedScroll

我们一个一个来看,先来看下dispatchNestedPreScroll做了那些事:

// NestedScrollView.java
    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
            int type) {
        return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

// NestedScrollingChildHelper.java
     public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
        // 子 view 要先使能嵌套滑动功能
        if (isNestedScrollingEnabled()) {
            // 根据触摸类型获取相应的父控件
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            // 如果存在滑动距离
            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                // 如果 consumeed 为null,则先初始化
                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[0] = 0;
                consumed[1] = 0;

                // 回调父控件方法:onNestedPreScroll
                ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                // true:父控件消费了滑动距离
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

所以,dispatchNestedPreScroll还是由辅助类 NestedScrollingChild 进行代理实现,其具体的实现方式就是回调父控件的onStartNestedScroll方法,让父控件优先选择是否消费部分或全部滑动距离。因此,dispatchNestedPreScroll的作用其实就是共享滑动距离,且让父控件具备更高的优先级消费这些滑动事件。

接着在来看下dispatchNestedScroll

// NestedScrollView.java
    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
            int dyUnconsumed, int[] offsetInWindow, int type) {
        return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                offsetInWindow, type);
    }

// NestedScrollingChildHelper.java
     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type) {
        if (isNestedScrollingEnabled()) {
            final ViewParent parent = getNestedScrollingParentForType(type);
            if (parent == null) {
                return false;
            }

            // 如果子 view 消费了滑动距离,或者是滑动距离还有剩余
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                // 通知父控件滑动距离详情
                ViewParentCompat.onNestedScroll(parent, mView, dxConsumed,
                        dyConsumed, dxUnconsumed, dyUnconsumed, type);

                if (offsetInWindow != null) {
                    mView.getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }

dispatchNestedScroll同样由NestedScrollingChildHelper辅助类代理实现,其实现的具体方式也很简单,就是当子 view 消费了滑动距离或者是滑动距离还有剩余的情况下,回调父控件onNestedScroll,如果有剩余滑动距离,则父控件可以进行消费。也就是说,dispatchNestedScroll提供一个让父控件在子 view 消费后还能继续消费剩余滑动距离的功能

那么总结一下,dispatchNestedPreScroll是让父控件优先消费滑动距离(在子 view 消费之前);而dispatchNestedScroll是让父控件延后消费剩余滑动距离(在子 view 消费之后)。

最后再来看下 NestedScrollView 对于 ACTION_UPACTION_CANCEL 事件的处理:

//NestedScrollView.java
    public boolean onTouchEvent(MotionEvent ev) {
        ...
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                ...
                break;
            }
            case MotionEvent.ACTION_MOVE:
                ...
                break;
            case MotionEvent.ACTION_UP:
                ...
                // 存在剩余距离
                if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                    flingWithNestedDispatch(-initialVelocity);
                } 
                ...
                // 做一些回收工作,以及调用 stopNestedScroll,通知父控件滑动事件结束
                endDrag();
                break;
            case MotionEvent.ACTION_CANCEL:
                ...
                // 做一些回收工作,以及调用 stopNestedScroll,通知父控件滑动事件结束
                endDrag();
                break;
            ...
        }
        ...
        return true;
    }

  
    private void flingWithNestedDispatch(int velocityY) {
        final int scrollY = getScrollY();
        final boolean canFling = (scrollY > 0 || velocityY > 0)
                && (scrollY < getScrollRange() || velocityY < 0);
        // 子 view 再消费 fling 事件前,先询问父控件看是否要消费该事件
        if (!dispatchNestedPreFling(0, velocityY)) {
            // 父控件不消费 fling 事件,则自己消费,并回传 fling 事件给父控件
            dispatchNestedFling(0, velocityY, canFling);
            // 开启新一轮滑动事件
            fling(velocityY);
        }
    }

    private void endDrag() {
        mIsBeingDragged = false;

        recycleVelocityTracker();
        // 子 view 通知父控件嵌套滑动结束
        stopNestedScroll(ViewCompat.TYPE_TOUCH);

        if (mEdgeGlowTop != null) {
            mEdgeGlowTop.onRelease();
            mEdgeGlowBottom.onRelease();
        }
    }

ACTION_CANCEL 事件中,NestedScrollView 直接调用stopNestedScroll通知父控件嵌套滑动结束;而在 ACTION_UP 事件中,会先判断下当前子 view 是否还存在惯性滑动速度,如果存在,则调用dispatchNestedPreFling询问父控件是否要消费该 fling 事件,如果父控件不消费,则调用dispatchNestedFling自己消费这个事件,并将结果通知到父控件。然后还有一个关键步骤,它开启了新的一轮嵌套滑动事件,开启方法如下:

 
    public void fling(int velocityY) {
        if (getChildCount() > 0) {
            // 开启新一轮嵌套滑动,此时的输入参数为:TYPE_NON_TOUCH,因为该滚动是 scroller 驱动的,不是用户
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
            // scroller 驱动 fling 滑动
            mScroller.fling(getScrollX(), getScrollY(), // start
                    0, velocityY, // velocities
                    0, 0, // x
                    Integer.MIN_VALUE, Integer.MAX_VALUE, // y
                    0, 0); // overscroll
            mLastScrollerY = getScrollY();
            // 最终调用的是View.invalidate()
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

即在 fling 函数中,通过调用startNestedScroll并传入参数 ViewCompat.TYPE_NON_TOUCH 来通知父控件开启新的一轮嵌套滑动事件,其驱动类型不是用户屏幕触碰(通常是 Scroller 驱动),NestedScrollView 中这里的 Scroller 是一个 OverScroller 类型,我们查看下 OverScroller 文档:

OverScroller

也就是说,OverScroller 在大多数情况下可以替换 android.widget.Scroller,所以这两者的原理应该是差不多的。

我们知道,android.widget.Scroller虽然名为 Scroller,但是它并不会真正起到滑动的功能,它其实只是负责一个计算的功能,然后通过View.invalidateView自己去重新绘制,从而实现 scroll 功能。因此,再 fling 函数开启新一轮嵌套滑动后,我们看到它最底部其实是有通过ViewCompat.postInvalidateOnAnimation(this)(该方法最终调用的就是view.invalidate),这样就实现了自动绘制 scroll 功能。而在View重绘时,其draw方法会被调用,该方法内又会调用到computeScroll方法,因此,我们来看下computeScroll

public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            ...
            // Dispatch up to parent
            if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
                dy -= mScrollConsumed[1];
            }

            if (dy != 0) {
                ...
                if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,
                        ViewCompat.TYPE_NON_TOUCH)) {
                    ...
                }
            }

            // Finally update the scroll positions and post an invalidation
            mLastScrollerY = y;
            ViewCompat.postInvalidateOnAnimation(this);
        } else {
            // We can't scroll any more, so stop any indirect scrolling
            if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
                stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
            }
            // and reset the scroller y
            mLastScrollerY = 0;
        }
    }

我们可以看到,在computeScroll方法中,会依次调用dispatchNestedPreScroll,dispatchNestedScroll,stopNestedScroll,并且参数都是 ViewCompat.TYPE_NON_TOUCH ,这样,非用户触碰的嵌套滑动事件就完成了。

至此,NestedScrollView 作为子控件的嵌套滑动机制分析完毕。

自定义控件:具备 嵌套滑动功能

下面我们来自定义两个具备 嵌套滑动 功能的控件,一个作为父控件,一个作为子控件。

效果如下图所示:

custom nestedscrolling view demo

要求:在向上滑动底部子控件时,父控件响应,直到图片完全隐藏;当向下滑动底部子控件时,子控件先进行滑动,直到到达顶部,如果此时图片未完全显示,则父控件响应后续的滑动事件,直到图片完全显示。

我们从上面要求出发,先定义一个具备嵌套滑动的父控件,源码如下:

package com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view;

import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.NestedScrollingParent2;
import android.support.v4.view.NestedScrollingParentHelper;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.LinearLayout;

public class MyNestedScrollingParent extends LinearLayout implements NestedScrollingParent2 {

    private NestedScrollingParentHelper mParentHelper;
    private int mFirstChildHeight;

    public MyNestedScrollingParent(Context context) {
        this(context, null);
    }

    public MyNestedScrollingParent(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyNestedScrollingParent(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public MyNestedScrollingParent(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init();
    }

    private void init() {
        this.mParentHelper = new NestedScrollingParentHelper(this);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        final View firstChild = this.getChildAt(0);
        if (firstChild == null)
            throw new IllegalStateException(String.format("%s must own a child view", MyNestedScrollingParent.class.getSimpleName()));
        firstChild.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                MyNestedScrollingParent.this.mFirstChildHeight = firstChild.getMeasuredHeight();
                firstChild.getViewTreeObserver().removeOnGlobalLayoutListener(this);
            }
        });

    }

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        // only cares vertical scroll
        return (ViewCompat.SCROLL_AXIS_VERTICAL & axes) != 0;
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
        this.mParentHelper.onNestedScrollAccepted(child, target, axes, type);
    }

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        boolean isFirstChildVisible = (dy > 0 && this.getScrollY() < this.mFirstChildHeight)
                || (dy < 0 && target.getScrollY() <= 0);
        if (isFirstChildVisible) {
            //consume dy
            consumed[1] = dy;
            this.scrollBy(0, dy);
        }
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
    }


    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        this.mParentHelper.onStopNestedScroll(target, type);
    }

    @Override
    public void scrollTo(int x, int y) {
        y = y < 0 ? 0 : y;
        y = y > this.mFirstChildHeight ? this.mFirstChildHeight : y;
        super.scrollTo(x, y);
    }
}

我们主要在onNestedPreScroll对子 view 滑动事件进行消费,如果出现一下两种情形之一,则父控件消费该嵌套滑动事件:

  • 如果 dy > 0,说明子控件向上滑动,此时,只要父控件的getScrollY(Y轴偏移量)小于图片(第一个子View)大小,说明图片未完全隐藏,则父控件需消耗这些滑动距离,直到图片完全隐藏。

  • 如果 dy < 0,说明子控件向下滑动,此时,如果子控件getScrollY(Y轴偏移量)小于等于0,说明子控件已到达顶部,则父控件进行响应。

注:如果区分不清楚 dy 是正是负,可以参考:十分钟Android中的嵌套滚动机制,里面有提及这一点。

接下来定义一个具备嵌套滑动的子控件,源码如下:

package com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view;

import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.support.annotation.Nullable;
import android.support.v4.view.NestedScrollingChild2;
import android.support.v4.view.NestedScrollingChildHelper;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.LinearLayout;

public class MyNestedScrollingChild extends LinearLayout implements NestedScrollingChild2 {

    private NestedScrollingChildHelper mChildHelper;
    private int mViewHeight;
    private int mCanScrollY;
    private int mLastMotionY;
    /**
     * Used during scrolling to retrieve the new offset within the window.
     */
    private final int[] mScrollOffset = new int[2];
    private final int[] mScrollConsumed = new int[2];


    public MyNestedScrollingChild(Context context) {
        this(context, null);
    }

    public MyNestedScrollingChild(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyNestedScrollingChild(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public MyNestedScrollingChild(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init();
    }

    private void init() {
        this.mChildHelper = new NestedScrollingChildHelper(this);
        this.mChildHelper.setNestedScrollingEnabled(true);
    }

    @Override
    public boolean startNestedScroll(int axes, int type) {
        return this.mChildHelper.startNestedScroll(axes, type);
    }

    @Override
    public void stopNestedScroll(int type) {
        this.mChildHelper.stopNestedScroll(type);
    }

    @Override
    public boolean hasNestedScrollingParent(int type) {
        return this.mChildHelper.hasNestedScrollingParent(type);
    }

    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
        return this.mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
    }

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
        return this.mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                this.mLastMotionY = (int) event.getY();
                this.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_MOVE:
                final int y = (int) event.getY();
                int deltaY = this.mLastMotionY - y;
                if (this.dispatchNestedPreScroll(0, deltaY, this.mScrollConsumed,
                        this.mScrollOffset, ViewCompat.TYPE_TOUCH)) {
                    deltaY -= this.mScrollConsumed[1];
                }
                this.scrollBy(0, deltaY);
                break;
            case MotionEvent.ACTION_UP:
                this.stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            case MotionEvent.ACTION_CANCEL:
                this.stopNestedScroll(ViewCompat.TYPE_TOUCH);
                break;
            default:
                break;
        }
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (this.mViewHeight <= 0) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            this.mViewHeight = this.getMeasuredHeight();
        } else {
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            this.mCanScrollY = this.getMeasuredHeight() - this.mViewHeight;
        }
    }

    @Override
    public void scrollTo(int x, int y) {
        y = y < 0 ? 0 : y;
        y = y > this.mCanScrollY ? this.mCanScrollY : y;
        super.scrollTo(x, y);
    }
}

这里主要有两点需要讲一下:

  1. 覆写onMeasure方法,由于子 view 具备滑动功能,所以其布局大小跟内容布局大小就不一样了。第一次执行onMeasure的时候,绘制得出的就是该 view 的布局大小,后续执行onMeasure的时候,其实就需要对内容大小进行计算,我们通过MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)构造出一个不受父控件控制大小,而由子控件自己决定自己的大小的测量参数,这样最终得到的就是子控件内容的大小。而由子控件内容大小与布局大小的差值就是子控件可以滑动的距离了。

  2. 覆写onTouchEvent方法,嵌套滑动机制 是子控件驱动的,因此需要在子控件事件函数中,在适当的时机调用相应方法,从而通知到父控件,这样嵌套滑动就完成了。

最后给出布局文件:

<?xml version="1.0" encoding="utf-8"?>
<com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view.MyNestedScrollingParent xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:scaleType="fitXY"
        android:src="@drawable/girl" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:background="#654321"
        android:gravity="center"
        android:text="Custom NestedScrolling View"
        android:textSize="20sp" />

    <com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view.MyNestedScrollingChild
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/large_text" />

    </com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view.MyNestedScrollingChild>

</com.yn.demos.masterial_design.NestedScrollView.nestedscrolling.view.MyNestedScrollingParent>

注:该示例只是实现了最简单的嵌套滑动,对于 fling 事件以及其他类型驱动事件(ViewCompat.TYPE_NON_TOUCH)并没有进行处理,旨在提供最简单框架了解 嵌套滑动机制 的实现方式。

上述示例参考自:Android中NestedScrollview的使用

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容