一点见解: Android嵌套滑动和NestedScrollView

2字数 3668阅读 22231

问题分析

嵌套滑动一直是Android中比较棘手的问题, 根本原因是Android的事件分发机制导致的.不了解事件分发机制的同学可以先看看一点见解: Android事件分发机制, 导致嵌套滑动难处理的关键原因在于当子控件消费了事件, 那么父控件就不会再有机会处理这个事件了, 所以一旦内部的滑动控件消费了滑动操作, 外部的滑动控件就再也没机会响应这个滑动操作了.

嵌套滑动

不过这个问题终于在LOLLIPOP(SDK21)之后终于有了官方的解决方法, 就是嵌套滑动机制. 在分析具体的代码逻辑之前, 下面先简单介绍下嵌套滑动的一些基本知识.
嵌套滑动机制可以理解为一个约定, 原生的支持嵌套滑动的控件都是依据这个约定来实现嵌套滑动的, 例如CoordinatorLayout, 所以如果你自定义的控件也遵守这个约定, 那么就可以跟原生的控件进行嵌套滑动了.

基本原理

嵌套滑动的基本原理是在子控件接收到滑动一段距离的请求时, 先询问父控件是否要滑动, 如果滑动了父控件就通知子控件它消耗了一部分滑动距离, 子控件就只处理剩下的滑动距离, 然后子控件滑动完毕后再把剩余的滑动距离传给父控件.
通过这样的嵌套滑动机制, 在一次滑动操作过程中

父控件和子控件都有机会对滑动操作作出响应, 尤其父控件能够分别在子控件处理滑动距离之前和之后对滑动距离进行响应.

这解决了事件分发机制缺点引起的问题.

版本之别

在看具体的代码之前先说下嵌套滑动相关方法的一些我认为值得注意的地方.

LOLLIPOP(SDK21)之后

为什么说这个是官方的解决方法? 因为

嵌套滑动的相关逻辑作为普通方法直接写进了最新的(SDK21之后)ViewViewGroup类.

普通方法是指这个方法不是继承自接口或者其他类, 例如[View#dispatchNestedScroll](https://developer.android.com/reference/android/view/View.html#dispatchNestedScroll(int, int, int, int, int[])), 可以看到官方标注了Added in API level 21标示, 也就是说这是在SDK21版本之后添加进去的一个普通方法.

向前兼容

而SDK21之前的版本

官方在android.support.v4兼容包中提供了两个接口NestedScrollingChildNestedScrollingParent, 还有两个辅助类NestedScrollingChildHelperNestedScrollingParentHelper来帮助控件实现嵌套滑动.

这个兼容的原理很简单

两个接口NestedScrollingChildNestedScrollingParent分别定义上面提到的ViewViewParent新增的普通方法

在嵌套滑动中会要求控件要么是继承于SDK21之后的ViewViewGroup, 要么实现了这两个接口, 这是控件能够进行嵌套滑动的前提条件.
那么怎么知道调用的方法是控件自有的方法, 还是接口的方法? 在代码中是通过ViewCompatViewParentCompat类来实现.

ViewCompatViewParentCompat通过当前的Build.VERSION.SDK_INT来判断当前版本, 然后选择不同的实现类, 这样就可以根据版本选择调用的方法.

例如如果版本是SDK21之前, 那么就会判断控件是否实现了接口, 然后调用接口的方法, 如果是SDK21之后, 那么就可以直接调用对应的方法.

辅助类

除了接口兼容包还提供了NestedScrollingChildHelperNestedScrollingParentHelper两个辅助类, 这两个辅助类实际上就是对应ViewViewParent中新增的普通方法, 代码就不贴了, 简单对比下就可以发现, 对应方法实现的逻辑基本一样, 所以

只要在接口方法内对应调用辅助类的方法就可以兼容嵌套滑动了.

例如在NestedScrollingChild#startNestedScroll方法中调用NestedScrollingChildHelper#startNestedScroll.
题外话: 这里实际用了代理模式来让SDK21之前的控件具有了新增的方法.

默认处理逻辑

虽然ViewViewGroup(SDK21之后)本身就具有嵌套滑动的相关方法, 但是默认情况是是不会被调用, 因为ViewViewGroup本身不支持滑动, 所以

本身不支持滑动的控件即使有嵌套滑动的相关方法也不能进行嵌套滑动.

上面已经说到要让控件支持嵌套滑动

  1. 首先要控件类具有嵌套滑动的相关方法, 要么仅支持SDK21之后版本, 要么实现对应的接口, 为了兼容低版本, 更常用到的是后者.
  2. 因为默认的情况是不会支持滑动的, 所以控件要在合适的位置主动调起嵌套滑动的方法.

接下来通过分析相对简单的支持嵌套滑动的容器NestedScrollView来了解下怎样主动调起嵌套滑动的方法, 以及嵌套滑动的具体逻辑.

相关方法

先简单看看相关方法的作用, 更具体的说明建议看源码注释中的方法说明.
注意 : 下文分析用内控件表示两层嵌套中的子控件, 外控件表示嵌套中的父控件.**

NestedScrollingChild

startNestedScroll : 起始方法, 主要作用是找到接收滑动距离信息的外控件.
dispatchNestedPreScroll : 在内控件处理滑动前把滑动信息分发给外控件.
dispatchNestedScroll : 在内控件处理完滑动后把剩下的滑动距离信息分发给外控件.
stopNestedScroll : 结束方法, 主要作用就是清空嵌套滑动的相关状态
setNestedScrollingEnabledisNestedScrollingEnabled : 一对get&set方法, 用来判断控件是否支持嵌套滑动.
dispatchNestedPreFlingdispatchNestedFling : 跟Scroll的对应方法作用类似, 不过分发的不是滑动信息而是Fling信息.(这个Fling好难翻译.. =。=)本文主要关注滑动的处理, 所以后续不分析这两个方法.

从方法名就可以看出

内控件是嵌套滑动的发起者.

NestedScrollingParent

因为内控件是发起者, 所以外控件的大部分方法都是被内控件的对应方法回调的.
onStartNestedScroll : 对应startNestedScroll, 内控件通过调用外控件的这个方法来确定外控件是否接收滑动信息.
onNestedScrollAccepted : 当外控件确定接收滑动信息后该方法被回调, 可以让外控件针对嵌套滑动做一些前期工作.
onNestedPreScroll : 关键方法, 接收内控件处理滑动前的滑动距离信息, 在这里外控件可以优先响应滑动操作, 消耗部分或者全部滑动距离.
onNestedScroll : 关键方法, 接收内控件处理完滑动后的滑动距离信息, 在这里外控件可以选择是否处理剩余的滑动距离.
onStopNestedScroll : 对应stopNestedScroll, 用来做一些收尾工作.
getNestedScrollAxes : 返回嵌套滑动的方向, 区分横向滑动和竖向滑动, 作用不大
onNestedPreFlingonNestedFling : 同上略

外控件通过onNestedPreScrollonNestedScroll来接收内控件响应滑动前后的滑动距离信息.

再次指出, 这两个方法是实现嵌套滑动效果的关键方法.

从NestedScrollView看嵌套机制

说完上面一大通, 终于可以开始分析源码来了解嵌套滑动机制起作用的具体逻辑了.
NestedScrollView简单地说就是支持嵌套滑动的ScrollView, 内部逻辑简单, 而且它既可以是内控件, 也可以是外控件, 所以选择分析它来了解嵌套滑动机制.

注意 : 因为NestedScrollingChildHelperNestedScrollingParent这两个辅助类的实现跟ViewViewGroup中的对应方法是一样的, 而且ViewViewGroup的源码没有使用兼容类, 所以下面分析相关方法的时候源码都使用ViewViewGroup中的代码.

上面已经说了嵌套滑动是从startNestedScroll开始, 所以先看看哪里调用了这个方法, 在源码里一搜就能知道有两个地方调用了这个方法.

  1. onInterceptTouchEventACTION_DOWN的情况
  2. onTouchEventACTION_DOWN的情况

因为ACTION_DOWN是滑动操作的开始事件, 所以当接收到这个事件的时候尝试找对应的外控件. 只有找到了外控件才有后续的嵌套滑动的逻辑发生.
关于NestedScrollView在这里的实现其实有个奇怪的地方, 提出一个问题, 不感兴趣的可以直接跳过这段.

  • 既然内控件是发起者, 为什么要在onInterceptTouchEvent也调用startNestedScroll呢?

因为事件传递的时候会先执行外控件的onInterceptTouchEvent, 也就是说第一个执行startNestedScroll的是最外层的NestedScrollView, 即使它找到了对应的外控件后续如果有子控件消费了这个事件, 也就是说不执行onTouchEvent方法, 那么找到外控件也没用的, 不清楚设计者的意图.

接着我们看startNestedScroll是如何找对应的外控件的, 因为NestedScrollView#startNestedScroll调用了辅助方法的startNestedScroll, 所以下面直接贴View#startNestedScroll.

// View.javapublic 
boolean startNestedScroll(int axes) { 
    // ... 
    if (isNestedScrollingEnabled()) { 
        ViewParent p = getParent(); 
        View child = this; 
        while (p != null) { 
            try { 
                // 关键代码 
                if (p.onStartNestedScroll(child, this, axes)) { 
                    mNestedScrollingParent = p; 
                    p.onNestedScrollAccepted(child, this, axes); 
                    return true; 
                }
            } catch (AbstractMethodError e) { 
                // ... 
            } 
            if (p instanceof View) { 
                child = (View) p; 
            } 
            p = p.getParent(); 
        } 
    } 
    return false;
}

非常简单的逻辑遍历父控件, 调用父控件的onStartNestedScroll, 返回true表示找到了对应的外控件, 找到外控件后马上调用onNestedScrollAccepted

从这里可以知道

外控件不一定是内控件的直接父控件, 但一定是最近的符合条件的外控件.

还可以确定了上面关于onStartNestedScroll的方法说明, 返回true表示接收内控件的滑动信息.对于NestedScrollView#onStartNestedScroll内部逻辑很简单, 只要是竖直滑动方向就返回true, 所以可以知道

NestedScrollView不支持横向嵌套滑动.

接着被调用的是onNestedScrollAccepted, 看NestedScrollView#onNestedScrollAccepted

// NestedScrollView.java
@Overridepublic void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) { 
        mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes); 
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
}

辅助类的方法很简单, 就是记录当前的滑动方向, 在这里NestedScrollView又调用startNestedScroll来找它自己的外控件, 这是为了连续嵌套NestedScrollView, 不过这是NestedScrollView自己的实现, 不管它.

找到了外控件后ACTION_DOWN事件就没嵌套滑动的事了, 要滑动肯定会在onTouchEvent中处理ACTION_MOVE事件, 接着我们看ACTION_MOVE事件是怎样处理的.

// NestedScrollView#onTouchEvent
case MotionEvent.ACTION_MOVE: 
    // ... 
    final int y = (int) MotionEventCompat.getY(ev, activePointerIndex); 
    int deltaY = mLastMotionY - y; 
    // 让外控件先处理滑动距离 
    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) { 
        deltaY -= mScrollConsumed[1];// 消耗滑动距离 
        // ... 
    } 
    // ... 
    if (mIsBeingDragged) { 
        // ... 
        // 内控件处理滑动距离 
        if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0, 
                      0, true) && !hasNestedScrollingParent()) { 
            // ... 
        } 

        final int scrolledDeltaY = getScrollY() - oldY; 
        final int unconsumedY = deltaY - scrolledDeltaY; 
        if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) { 
            // ... 
        } 
        // ... 
    } 
    break;

这部分是NestedScrollView能够处理嵌套滑动的关键代码了, 其他能够嵌套滑动的控件也应该在ACTION_MOVE中类似地处理滑动距离.

先计算出本次滑动距离deltaY, 这里有个小细节

deltaY等于上一次的Y坐标减去这次的Y坐标, 这意味着在相关方法中接收到的滑动距离参数中, 滑动距离 > 0表示手指向下滑动, 反之表示手指向上滑动. 这是因为在屏幕中Y轴正方向是向下的.

得到滑动距离deltaY后, 先把它传给dispatchNestedPreScroll, 然后在结果返回true的时候, delta会减去mScrollConsumed[1].

接着看dispatchNestedPreScroll干了什么

// View.java
public boolean dispatchNestedPreScroll(int dx, int dy,
                     @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
    // ... 忽略状态判断 
    consumed[0] = 0; 
    consumed[1] = 0; 
    mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed); 
    return consumed[0] != 0 || consumed[1] != 0; 
    // 其他情况返回false
}

忽略条件判断和offsetInWindow的相关处理, 先指出consumed就是上一步分析中的mScrollConsumed, dy就是deltaY.
因为dispatchNestedPreScroll的工作就是把滑动距离在内控件处理前分发给外控件, 所以这里的关键代码也很简单, 就是直接把相关的参数传给外控件的onNestedPreScroll, 然后只要外控件消耗了滑动距离(不论横向还是竖向), 就会返回true

所以

外控件如果想在内控件之前消耗滑动距离仅需要在onNestedPreScroll把消耗的值放到数组中返回给内控件.

onNestedPreScroll是决定外控件的嵌套滑动逻辑的关键方法, 在不同的控件中应该是根据需要有不同的实现的, 而在NestedScrollView中就是直接询问它自己的外控件是否消耗滑动距离, 实现比较简单就不贴代码了.

在这里提醒下, 在我们自己修改嵌套滑动逻辑的时候需要注意滑动距离的正负号和内控件处理consumed数组的方式. 不过这些都是些数字游戏, 不细说了.

好了, 现在外控件已经比内控件先处理了滑动距离了, 如果外控件没有完全消耗掉所有滑动距离, 这时该内控件处理剩下的滑动距离了, 不同的控件有不同的滑动实现, 在NestedScrollView中通过NestedScrollView#overScrollByCompat来进行滑动, 并且滑动结束后通过比对滑动前后的scrollY值得到了内控件消耗的滑动距离, 然后得到剩下的滑动距离, 最后传给dispatchNestedScroll.

dispatchNestedScroll的逻辑跟dispatchNestedPreScroll几乎一样, 区别是它调用了外控件的onNestedScroll, 因为到这里已经是处理滑动距离最后的机会了, 所以onNestedScroll不会再影响内控件的处理逻辑了.

到这里ACTION_MOVE事件就分析完毕了.

最后就是stopNestedScroll了, 代码就不贴了, 调用这个方法基本是新的滑动操作开始前, 或者滑动操作结束/取消, 代码逻辑就是进行一些变量的重置工作和调用onStopNestedScroll, 而onStopNestedScroll也类似.

整个嵌套滑动的基本逻辑就是这样. 注意这里虽然分析的是NestedScrollView, 但这代表了嵌套滑动的"约定"处理方式, 虽然不同的控件实际的实现会有不同不过应该遵循基本方法的调用顺序, 确保参数的含义和参数的处理方式.

总结

  1. 如果要支持嵌套滑动, 内控件和外控件要支持对应的方法, 为了兼容低版本一般通过实现NestedScrollingChildNestedScrollingParent接口以及使用NestedScrollingChildHelperNestedScrollingParent辅助类.
  2. 具体嵌套滑动逻辑主要是在onNestedPreScrollonNestedScroll方法中.
  3. 父控件通过给数组赋值来把消耗的滑动距离传递给内控件.

当你希望滑动内部列表的时候先把列表顶部的控件隐藏掉, 例如ActionBar, 这时候嵌套滑动就大有用处了, 具体的应用场景可以看看Android 嵌套滑动机制(NestedScrolling)的实现效果.

感觉这篇说得有些零碎, 如果有改进的建议欢迎在讨论区指出. :D

推荐阅读更多精彩内容