【透镜系列】看穿 > NestedScrolling 机制 >

(转载请注明做者:RubiTree,地址:blog.rubitree.comjava

NestedScrolling 机制翻译过来叫嵌套滑动机制(本文将混用),它提供了一种优雅解决嵌套滑动问题的方案,具体是什么方案呢?咱们从嵌套的同向滑动提及。android

1. 嵌套同向滑动

1.1. 嵌套同向滑动的问题

所谓嵌套同向滑动,就是指这样一种状况:两个可滑动的View内外嵌套,并且它们的滑动方向是相同的。 git

-w350

这种状况若是使用通常的处理方式,会出现交互问题,好比使用两个ScrollView进行布局,你会发现,触摸着内部的ScrollView进行滑动,它是滑不动的 (不考虑后来 Google 给它加的NestedScroll开关)github

1.2. 分析问题缘由

(舒适提示:本文涉及事件分发的内容比较多,建议对事件分发不太熟悉的同窗先阅读另外一篇透镜《看穿 > 触摸事件分发》数组

若是你熟悉 Android 的触摸事件分发机制,那么缘由很好理解:两个ScrollView嵌套时,滑动距离终于达到滑动手势断定阈值(mTouchSlop)的这个MOVE事件,会先通过父 View 的onInterceptTouchEvent()方法,父 View 因而直接把事件拦截,子 View 的onTouchEvent()方法里虽然也会在断定滑动距离足够后调用requestDisallowInterceptTouchEvent(true),但始终要晚一步。app

而这个效果显然是不符合用户直觉的 那用户但愿看到什么效果呢?框架

  1. 大部分时候,用户但愿看到:当手指触摸内部ScrollView进行滑动时,能先滑动内部的ScrollView,只有当内部的ScrollView滑动到尽头时,才滑动外部的ScrollView

这看上去很是天然,也跟触摸事件的处理方式一致,但相比触摸事件的处理,要在滑动时实现一样的效果却会困难不少ide

  1. 由于滑动动做不能马上识别出来,它的处理自己就须要经过事件拦截机制进行,而事件拦截机制实质上跟《看穿 > 触摸事件分发》中第一次试造的轮子同样,只是单向的,并且方向从外到内,因此没法作到:先让内部拦截滑动,内部不拦截滑动时,再在让外部拦截滑动

那能不能把事件拦截机制变成双向的呢?不是不行,但这显然违背了拦截机制的初衷,并且它很快会发展成无限递归的:双向的事件拦截机制自己是否也须要一个拦截机制呢?因而有了拦截的拦截,而后再有拦截的拦截的拦截... 布局

-w150

1.3. 尝试解决问题

换一个更直接的思路,若是咱们的需求始终是内部滑动优先,那是否可让外部 View「拦截滑动的断定条件」比内部 View「申请外部不拦截的断定条件」更严格,从而让滑动距离每次都先达到「申请外部不拦截的断定条件」,子 View 就可以在父 View 拦截事件前申请外部不拦截了。 能看到在ScrollView中,「拦截滑动的断定条件」和「申请外部不拦截的断定条件」都是Math.abs(deltaY) > mTouchSlop,咱们只须要增大「拦截滑动的断定条件」时的mTouchSlop就好了。post

但实际上这样作并很差,由于mTouchSlop到底应该增长多少,是件不肯定的事情,手指滑动的快慢和屏幕的分辨率可能都会对它有影响。 因此能够换一种实现,那就是让第一次「拦截滑动的断定条件」成立时,先不进行拦截,若是内部没有申请外部不拦截,第二次条件成立时,再进行拦截,这样也一样实现了开始的思路。 因而继承 ScrollView,覆写它的onInterceptTouchEvent()

class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
    private var isFirstIntercept = true
    
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            isFirstIntercept = true
        }

        val result = super.onInterceptTouchEvent(ev)

        if (result && isFirstIntercept) {
            isFirstIntercept = false
            return false
        }

        return result
    }
}    
复制代码

它的效果是这样,能看到确实实现了让内部先获取事件:

1.4. 第一次优化

但咱们但愿体验能更好一点,从上图能看到,内部即便在本身没法滑动的时候,也会对事件进行拦截,没法经过滑动内部来让外部滑动。其实内部应该在本身没法滑动的时候,直接在onTouchEvent()返回false,不触发「申请外部不拦截的断定条件」,就能让内外都有机会滑动。 这个要求很是通用并且合理,在SimpleNestedScrollView基础上进行简单修改,加上如下代码:

private var isNeedRequestDisallowIntercept: Boolean? = null

override fun onTouchEvent(ev: MotionEvent): Boolean {
    if (ev.actionMasked == MotionEvent.ACTION_DOWN) isNeedRequestDisallowIntercept = null
    if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
        if (isNeedRequestDisallowIntercept == false) return false

        if (isNeedRequestDisallowIntercept == null) {
            val offsetY = ev.y.toInt() - getInt("mLastMotionY")
            if (Math.abs(offsetY) > getInt("mTouchSlop")) { // 滑动距离足够判断滑动方向是上仍是下后
                // 判断本身是否能在对应滑动方向上进行滑动(不能则返回false)
                if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
                    isNeedRequestDisallowIntercept = false
                    return false
                }
            }
        }
    }

    return super.onTouchEvent(ev)
}

private fun isScrollToTop() = scrollY == 0

private fun isScrollToBottom(): Boolean {
    return scrollY + height - paddingTop - paddingBottom == getChildAt(0).height
}
复制代码
  1. 其中getInt("mLastMotionY")getInt("mTouchSlop")为反射代码,获取私有的mLastMotionYmTouchSlop属性
  2. 这段代码省略了多点触控状况的判断

运行效果以下:

这样就完成了对嵌套滑动View最基本的需求:你们都能滑了。

后来我发现了一种更野的路子,不用当心翼翼地让改动尽可能小,既然内部优先,彻底可让内部的ScrollViewDOWN事件的时候就申请外部不拦截,而后在滑动一段距离后,若是判断本身在该滑动方向没法滑动,再取消对外部的拦截限制,思路是相似的但代码更简单。

class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) parent.requestDisallowInterceptTouchEvent(true)
        
        if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
            val offsetY = ev.y.toInt() - getInt("mLastMotionY")

            if (Math.abs(offsetY) > getInt("mTouchSlop")) {
                if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
        
        return super.dispatchTouchEvent(ev)
    }
}
复制代码

运行的效果跟上面是同样的,不重复贴图了。

1.5. 第二次优化

但这两种方式目前为止都没有实现最好的交互体验,最好的交互体验应该让内部不能滑动时,能接着滑动外部,甚至在你滑动过程当中快速抬起时,接下来的惯性滑动也能在两个滑动View间传递。

因为滑动这个交互的特殊性,咱们能够在外部对它进行操做,因此连续滑动的实现很是简单,只要重写scrollBy就行了,因此在已有代码的基础上再加上下面的代码(上面的两种思路都是加同样的代码):

override fun scrollBy(x: Int, y: Int) {
    if ((y > 0 && isScrollToTop()) || (y < 0 && isScrollToBottom())) {
        (parent as View).scrollBy(x, y)
    } else {
        super.scrollBy(x, y)
    }
}
复制代码

效果以下:

而惯性滑动的实现就会相对复杂一点,得对computeScroll()方法下手,要作的修改会多一些,这里暂时不去实现了,但作确定是没问题的。

1.6. 小结

到这里咱们对嵌套滑动交互的理解基本已经很是通透了,知道了让咱们本身实现也就那么回事,主要须要解决下面几个问题:

  1. 在内部 View 能够滑动的时候,阻止外部 View 拦截滑动事件,先滑动内部 View
  2. 在用户一次滑动操做中,当内部 View 滑动到终点时,切换滑动对象为外部 View,让用户可以连续滑动
  3. 在用户快速抬起触发的惯性滑动中,当内部 View 滑动到终点时,切换滑动对象为外部 View,让惯性可以连续

这时就能够来看看看系统提供的 NestedScrolling 机制是怎么完成嵌套滑动需求的,跟咱们的实现相比,有什么区别,是更好仍是更好?

(转载请注明做者:RubiTree,地址:blog.rubitree.com

2. NestedScrolling 机制

2.1. 原理

与咱们不一样,咱们只考虑了给ScrollView增长支持嵌套滑动的特性,但系统开发者须要考虑给全部有滑动交互的 View 增长这个特性,因此一个直接的思路是在 View 里加入这个机制。

那么要怎么加,加哪些东西呢?

  1. 进一步梳理前面要解决的问题,在嵌套滑动中,是能明确区分两类做用对象的:一个是内部 View,一个是外部 View。并且它们的主被动关系也很是明确:由于内部 View 离手指更近,咱们确定但愿它能优先消费事件,但咱们同时还但愿在某些状况下事件能在内部不消耗的时候给外部消耗,这固然也是让内部来控制,因此内部是主动,外部是被动回到空气马达
  2. 由此整个嵌套滑动的过程能够认为是这样的:触摸事件交给内部 View 进行消费,内部 View 执行相关逻辑,在合适的时候对外部 View 进行必定的控制,二者配合实现嵌套滑动
  3. 这就包括了两部分逻辑:
    1. 内部 View 中的主动逻辑:须要主动阻止外部 View 拦截事件,须要本身进行滑动,并在合适的时候让外部 View 配合进行剩下的滑动
      1. 这部分是核心内容,前面咱们本身实现的也是这部份内容
    2. 外部 View 中的被动逻辑
      1. 基本就是配合行动了,这部分逻辑很少
  4. 因为View里是不能放其余View的,它只能是内部的、主动的角色,而ViewGroup既能够放在另外一ViewGroup里,它里边也能够放其余的View,因此它能够是内部的也能够是外部的角色
  5. 这正好符合ViewViewGroup的继承关系,因此一个很天然的设计是:在View中加入主动逻辑,在ViewGroup中加入被动逻辑

由于不是每一个ViewViewGroup都可以滑动,滑动只是众多交互中的一种,ViewViewGroup不可能直接把全部事情都作了而后告诉你:Android 支持嵌套滑动了哦~ 因此 Google 加入的这些逻辑其实都是帮助方法,相关的View须要选择在合适的时候进行调用,最后才能实现嵌套滑动的效果。

先不说加了哪些方法,先说 Google 但愿能帮助你实现一个什么样的嵌套滑动效果:

  1. 从逻辑上区分嵌套滑动中的两个角色:ns childns parent,对应了上面的内部 View 和外部 View
    1. 注:1)这里我用「ns」表示nested scroll的缩写;2)为何叫逻辑上?由于实际上它容许你一个 View 同时扮演两个角色
  2. ns child会在收到DOWN事件时,找到本身祖上中最近的能与本身匹配的ns parent,与它进行绑定并关闭它的事件拦截机制
  3. 而后ns child会在接下来的MOVE事件中断定出用户触发了滑动手势,并把事件流拦截下来给本身消费
  4. 消费事件流时,对于每一次MOVE事件增长的滑动距离:
    1. ns child并非直接本身消费,而是先把它交给ns parent,让ns parent能够在ns child以前消费滑动
    2. 若是ns parent没有消费或是没有消费完,ns child再本身消费剩下的滑动
    3. 若是ns child本身仍是没有消费完这个滑动,会再把剩下的滑动交给ns parent消费
    4. 最后若是滑动还有剩余,ns child能够作最终的处理
  5. 同时在ns childcomputeScroll()方法中,ns child也会把本身由于用户fling操做引起的滑动,与上一条中用户滑动屏幕触发的滑动同样,使用「parent -> child -> parent -> child」的顺序进行消费

注:

  1. 以上过程参考当前最新的androidx.core 1.1.0-alpha01中的NestedScrollViewandroidx.recyclerView 1.1.0-alpha01中的RecyclerView实现,与以前的版本细节略有不一样,后文会详述其中差别
  2. 为了理解上的方便,有几处细节的描述作了简化:其实在NestedScrollViewRecyclerView这类经典实现中: 1. 在 ns child 滚动时,只要用户手指一按下,ns child 就会拦截事件流,不用等到判断出滑动手势(具体能够关注源码中的 mIsBeingDragged 字段) 1. 这个细节是合理的,会让用户体验更好 2. (后文将不会对这个细节再作说明,而是直接用简化的描述,实现时若是要提升用户体验,须要注意这个细节) 1. 按照 Android 的触摸事件分发规则,若是 ns child 内部没有要消费事件的 View,事件也将直接交给 ns childonTouchEvent() 消费。这时在 NestedScrollViewns child 的实现中,接下来onTouchEvent() 里判断出用户是要滑动本身以前,就会把用户的滑动交给 ns parent 进行消费回到4.4 1. 这个设计我我的以为不太合理,既然是传递滑动那就应该在判断出用户确实在滑动以后才开始传递,而不是这样直接传递,并且在后文的实践部分,你确实能看到这种设计带来的问题 1. (后文的描述中若是没有特别说明,也是默认忽略这个细节)
  3. 描述中省略了关于直接传递 fling 的部分,由于这块的设计存在问题,并且最新版本这部分机制的做用已经很是小了,后面这点会详细讲

你会发现,这跟咱们本身实现嵌套滑动的方式很是像,但它有这些地方作得更好(具体怎么实现的见后文)

  1. ns child使用更灵活的方式找到和绑定本身的ns parent,而不是直接找本身的上一级结点
  2. ns childDOWN事件时关闭ns parent的事件拦截机制单独用了一个 Flag 进行关闭,这就不会关闭ns parent对其余手势的拦截,也不会递归往上关闭祖上们的事件拦截机制。ns child直到在MOVE事件中肯定本身要开始滑动后,才会调用requestDisallowInterceptTouchEvent(true)递归关闭祖上们所有的事件拦截
  3. 对每一次MOVE事件传递来的滑动,都使用「parent -> child -> parent -> child」机制进行消费,让ns child在消费滑动时与ns parent配合更加细致、紧密和灵活
  4. 对于由于用户fling操做引起的滑动,与用户滑动屏幕触发的滑动使用一样的机制进行消费,实现了完美的惯性连续效果

2.2. 使用

到这一步,咱们再来看看 Google 给 View 和 ViewGroup 加了哪些方法?又但愿咱们何时怎么去调用它们?

加入的须要你关心的方法一共有这些(只注明了关键返回值和参数,参考当前最新的版本 androidx.core 1.1.0-alpha01):

// 『View』
setNestedScrollingEnabled(true)                       // 调用
startNestedScroll()                                   // 调用
dispatchNestedPreScroll(int delta, int[] consumed)    // 调用
dispatchNestedScroll(int unconsumed, int[] consumed)  // 调用
stopNestedScroll()                                    // 调用

// 『ViewGroup』
boolean onStartNestedScroll()                       // 覆写
int getNestedScrollAxes()                           // 调用
onNestedPreScroll(int delta, int[] consumed)        // 覆写
onNestedScroll(int unconsumed, int[] consumed)      // 覆写
复制代码

怎么调用这些方法取决于你要实现什么角色

  1. 在你实现一个ns child角色时,你须要:
    1. 在实例化的时候调用setNestedScrollingEnabled(true),启用嵌套滑动机制
    2. DOWN事件时调用startNestedScroll()方法,它会「找到本身祖上中最近的与本身匹配的ns parent,进行绑定并关闭ns parent的事件拦截机制」
    3. 在判断出用户正在进行滑动后
      1. 先常规操做:关闭祖上们所有的事件拦截,同时拦截本身子 View 的事件
      2. 而后调用dispatchNestedPreScroll()方法,传入用户的滑动距离,这个方法会「触发ns parent对滑动的消费,而且把消费结果返回」
      3. 而后ns child能够开始本身消费剩下滑动
      4. ns child本身消费完后调用dispatchNestedScroll()方法,传入最后没消费完的滑动距离,这个方法会继续「触发ns parent对剩下滑动的消费,而且把消费结果返回」
      5. ns child拿到最后没有消费完的滑动,作最后的处理,好比显示 overscroll 效果,好比在 fling 的时候中止scroller
    4. 若是你但愿惯性滑动也能传递给ns parent,那么在ViewcomputeScroll()方法中,对于每一个scroller计算到的滑动距离,与MOVE事件中处理滑动同样,按照这个顺序进行消费:「dispatchNestedPreScroll() -> 本身 -> dispatchNestedScroll() -> 本身」
    5. UPCANCEL事件中以及computeScroll()方法中惯性滑动结束时,调用stopNestedScroll()方法,这个方法会「打开ns parent的事件拦截机制,并取消与它的绑定」
  2. 在你实现一个ns parent角色时,你须要:
    1. 重写方法boolean onStartNestedScroll(View child, View target, int nestedScrollAxes),经过传入的参数,决定本身对这类嵌套滑动感兴趣,在感兴趣的状况中返回truens child就是经过遍历全部ns parent的这个方法来找到与本身匹配的ns parent
    2. 若是选择了某种状况下支持嵌套滑动,那么在拦截滑动事件前,调用getNestedScrollAxes(),它会返回你某个方向的拦截机制是否已经被ns child关闭了,若是被关闭,你就不该该拦截事件了
    3. 开启嵌套滑动后,你能够在onNestedPreScrollonNestedScroll方法中耐心等待ns child的消息,没错,它就对应了你在ns child中调用的dispatchNestedPreScrolldispatchNestedScroll方法,你能够在有必要的时候进行本身的滑动,而且把消耗掉的滑动距离经过参数中的数组返回

这么实现的例子能够看 ScrollView,只要打开它的setNestedScrollingEnabled(true)开关,你就能看到嵌套滑动的效果:(实际上ScrollView实现的不是完美的嵌套滑动,缘由见下一节)

ns parent还好,但ns child的实现还会有大量的细节(包括实践部分会提到的「ns parent偏移致使的 event 校订」等等),光是描述可能不够直接,为此我也为ns child准备了一份参考模板:NestedScrollChildSample

注意

  1. 虽然模板在IDE里不会报错,但这不是能够运行的代码,这是剔除 NestedScrollView 中关于 ns parent 的部分,获得的能够认为是官方推荐的 ns child 实现
  2. 同时,为了让主线逻辑更加清晰,删去了多点触控相关的逻辑,实际开发若是须要,能够直接参考 NestedScrollView 中的写法,不会麻烦太多*(有空会写多点触控的透镜系列XD)*
  3. 其中的关键部分是在触摸和滚动时怎么调用 NestedScrollingChild 接口的方法,也就是 onInterceptTouchEvent()onTouchEvent()computeScroll() 中大约 200 行的代码

另外,以上都说的是单一角色时的使用状况,有时候你会须要一个 View 扮演两个角色,就须要再多作一些事情,好比对于ns parent,你要时刻注意你也是 ns child,在来生意的时候也照顾一下本身的ns parent,这些能够去看 NestedScrollView 的实现,不在这展开了。

(转载请注明做者:RubiTree,地址:blog.rubitree.com

3. 历史的消防车滚滚向前

可是有人就了:回到答案

  1. 我怎么看到别人讲,你必须实现NestedScrollingParentNestedScrollingChild这两个接口,而后利用上NestedScrollingParentHelperNestedScrollingChildHelper这两个帮助类,才能实现一个支持嵌套滑动的自定义 View 啊,并且你们都称赞这是一种很棒的设计呢,怎么到你这就变成了直接加在View和 ViewGroup 里的方法了,这么普通的 DISCO 嘛?并且题图里也看到有这几个接口的啊,你难道是标题党吗?(赞一个竟然还记得题图)
  2. 为何不用实现接口也能实现嵌套滑动,又为何几乎全部实现嵌套滑动的 View 又都实现了这两个接口呢?
  3. 为何明明嵌套滑动机制在NestedScrollingParentNestedScrollingChild这两个接口里放了那么多方法,你却只讲9个呢?
  4. 为何接口里的 fling 系列方法你不讲?
  5. 为何有NestedScrollingChild,有NestedScrollingChild2,工做不饱和的同窗会发现最近 Google 还增长了NestedScrollingChild3,这都是在干哈?改了些什么啊?

别着急,要解释这些问题,还得先来了解下历史,翻翻sdksupport library家的老黄历: (嫌弃太长也能够直接前往观看小结(事情要从五年前提及...)

3.1. 第一个版本,2014年9月

Android 5.0 / API 21 (2014.9) 时, Google 第一次加入了 NestedScrolling 机制。

虽然在版本更新里彻底没有提到,可是在ViewViewGroup 的源码里你已经能看到其中的嵌套滑动相关方法。 并且此时使用了这些方法实现了嵌套滑动效果的 View 其实已经有很多了,除了咱们讲过的ScrollView,还有AbsListViewActionBarOverlayLayout等,而这些也基本是当时全部跟滑动有关的 View 了。 因此,如上文嵌套ScrollView的例子所示,在Android 5.0时你们其实就能经过setNestedScrollingEnabled(true)开关启用 View 的嵌套滑动效果。

这是 NestedScrolling 机制的初版实现。

3.2. 重构第一个版本,2015年4月

由于第一个版本的 NestedScrolling 机制是加在 framework 层的 View 和 ViewGroup 中,因此能享受到嵌套滑动效果的只能是Android 5.0的系统,也就是当时最新的系统。 你们都知道,这样的功能不会太受开发者待见,因此在当时 NestedScrolling 机制基本没有怎么被使用。(因此你们一说嵌套滑动就提后来才发布的NestedScrollView而不不知道ScrollView早就能嵌套滑动也是很是正常了)

Google 就以为,这可不行啊,嵌套滑不动的Bug不能老留着啊 好东西得你们分享啊,因而一狠心,梳理了下功能,重构出来两个接口(NestedScrollingChildNestedScrollingParent)两个 Helper (NestedScrollingChildHelperNestedScrollingParentHelper)外加一个开箱即用的NestedScrollView,在 Revision 22.1.0 (2015.4) 到来之际,把它们一块加入了v4 support library豪华午饭。

这下大伙就开心了,奔走相告:嵌套滑动卡了吗,赶忙上NestedScrollView吧,Android 1.6也能用。 同时NestedScrollingChildNestedScrollingParent也被你们知晓了,要本身整个嵌套滑动,那就实现这两接口吧。

随后,在下一个月 Revision 22.2.0 (2015.5)时,Google又隆重推出了 Design Support library,其中的杀手级控件CoordinatorLayout更是把 NestedScrolling 机制玩得出神入化。

NestedScrolling 机制终于走上台前,一时风头无两。

但注意,我比较了一下,这时的 NestedScrolling 机制相比以前放在 View 和 ViewGroup 中的第一个版本,其实彻底没有改动,只是把 View 和 ViewGroup 里的方法分红两部分放到接口和 Helper 里了,NestedScrollView里跟嵌套滑动有关的部分也跟ScrollView里的没什么区别,因此此时的 NestedScrolling 机制本质仍是第一个版本,只是形式发生了变化。

而 NestedScrolling 机制形式的变化带来了什么影响呢?

  1. 把 NestedScrolling 机制从 View 和 ViewGroup 中剥离,把有关的 API 放在接口中,把相关实现放在 Helper 里,让每个普通的低版本的 View 都能享受到嵌套滑动带来的乐趣,这就是它存在的意义啊(误
  2. 确实,由于这个机制其实不涉及核心的 framework 层的东西,因此让它脱离 API 版本存在,让低版本系统也能有嵌套滑动的体验,才是致使这个变化的主要缘由也是它的主要优势。至于依赖倒置、组合大于继承应该都只是结果。而便于修复 Bug(×2) 什么的 Google 当时大概也没有想到。
  3. 同时,这么作确定也不止有有优势,它也会有缺点,不然一开始就不会直接把机制加到 View 和 ViewGroup 里了,它的主要缺点有:
    1. 使用麻烦。这是确定的,原本放在 View 里拿来就用的方法,如今不只要实现接口,还要本身去写接口的实现,虽然有 Helper 类进行辅助,但仍是麻烦啊
    2. 暴露了更多内部的不须要普通使用者关心的 API。这点我认为比上一点要重要一些,由于它会影响开发者对整个机制的上手速度。原本,如我前文介绍,你只须要知道有这9个方法就行,如今这一改,光 child 里就有9个,parent 里还有8个,接近 double 了。多的这些方法中有的是机制内部用来沟通的(好比isNestedScrollingEnabled()onNestedScrollAccepted()),有的是设计别扭用得不多的(好比dispatchNestedFling()),有的是须要特别优化细节才须要的(好比hasNestedScrollingParent()),一开始开发者其实彻底不用关心。

3.2.1. 第一个版本的Bug

Android 1.6也用上了嵌套滑动,老奶奶开心得合不拢嘴。但你们用着用着,新鲜感过去以后,也开始不知足了起来,因而就有了初版 NestedScrolling 机制的著名Bug:「惯性不连续」回到小结

什么是惯性不连续?以下图

简单说就是:你在滑动内部 View 时快速抬起手指,内部 View 会开始惯性滑动,当内部 View 惯性滑动到本身顶部时便中止了滑动,此时外部的可滑动 View 不会有任何反应,即便外部 View 能够滑动。 原本这个体验也没多大问题,但由于你手动滑动的时候,内部滑动到顶部时能够接着滑动外边的 View,这就造成了对比,有对比就有差距,有差距群众就不满意了,你不能在惯性滑动的时候也把里面的滑动传递到外面去吗? 因此这个问题也不能算是 Bug,只是体验没有作到那么好罢了。

其实 Google 不是没有考虑过惯性,其中关于 fling 的4个 API 更是存在感十足地告诉你们,我就是来处理大家说的这档子事的,但为何仍是有 Bug 呢,那就不得不提这4个 API 的奇葩设计和用法了。

这四个 API 长这样,看名字对应上 scroll 的4个 API 大概能知道是干什么的(但实际上有很大区别,见下文):

  1. ns child:dispatchNestedPreFlingdispatchNestedFling
  2. ns parent:onNestedPreFlingonNestedFling

前面我在讲述的时候默认是让ns child直接消费用户快速抬起时产生的惯性滑动,这没有什么问题,由于咱们还在computeScroll方法中把惯性引发的滑动也传递给了ns parent,让父子配合进行惯性滑动。 但实际上此时的NestedScrollView是这么写的:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll();
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        if (canFling) fling(velocityY);
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() > 0) {
        ...
    
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0, Math.max(0, bottom - height), 0, height/2);
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        ... // 没有关于把滑动分发给 ns parent 的逻辑
    }
}
复制代码

来读一下其中的逻辑

  1. 首先看 API ,同滑动同样,设计者给惯性(速度)也设计了一套协同消费的机制,可是这套机制与滑动不太同样,或者说彻底不一样
  2. 在用户滑动ns child并快速抬起手指产生惯性的时候,看flingWithNestedDispatch()方法,ns child会先问ns parent是否消费此速度
    1. 若是消费,就把速度所有交出,本身再也不消费
    2. 若是ns parent不消费,那么将再次把速度交给ns parent,而且告诉它本身是否有消费速度的条件*(根据系统类库一向的写法,若是ns child消费这个速度,ns parent都不会对这个速度作处理)*,同时本身在有消费速度的条件时,对速度进行消费
  3. 本身消费速度的方式是使用mScroller进行惯性滑动,可是在computeScroll()中并无把滑动分发给 ns parent
  4. 最后只要抬起手指,就会调用stopNestedScroll()解除与ns parent的绑定,宣告此次协同合做到此结束

那么总结一下:

  1. 惯性的这套协同消费机制只能在惯性滑动前让ns parent有机会拦截处理惯性,它并不能在惯性滑动过程当中让ns childns parent协同消费惯性引起的滑动,也就是实现不了前面人们指望的惯性连续效果,因此初版的开发者想用直接传递惯性的方式实现惯性连续可能不是个好主意
    1. 另外,目前惯性的协同消费机制只会在ns child没法进行滑动的时候起到必定的做用(虽然彻底能够用滑动的协同消费机制替代),而在以后的版本中,这个做用基本也没有被用到,它确实被滑动的协同消费机制替代了
  2. 而实现惯性连续的方式其实很是简单,不须要增长新的机制,直接经过滑动的协同消费机制,在ns child进行惯性滑动时,把滑动传递出来,就能够了
  3. 因此初版 NestedScrolling 机制自己是没有问题的,有问题的是那些系统控件使用这个机制的方式不对
  4. 因此修复这个Bug也很简单,只是比较繁琐:修改全部做为ns child角色使用了嵌套滑动机制的系统控件,惯性相关的 API 和处理逻辑均可以保留,只要在computeScroll()中把滑动用dispatchNestedPreScroll()dispatchNestedScroll()方法分发给 ns parent,再更改一下解除与ns parent绑定的时机,放在 fling 结束以后
  5. 你本身的ns child View 能够直接改,但系统提供的NestedScrollViewRecyclerView等控件,你就只能提个 issue 等官方修复了,不过也能够拷贝一份出来本身改

3.3. 第二个版本,2017年9月

Google表示才不想搭理这些人,给你用就不错了哪来那么多事儿?我还要忙着搞AI呢 直到两年多后的2017年9月,Revision 26.1.0才悄咪咪 更新日志里没有提,可是文档的添加记录里能看到,后来发现做者本身却是写了篇博客说这事,说是Revision 26.0.0-beta2时加的,跟文档里写的不一致,不过这不重要) 更新了一版NestedScrollingChild2NestedScrollingParent2,而且处理了初版中系统控件的Bug,这即是第二个版本的 NestedScrolling 机制了

来看看第二版是怎么处理初版 Bug 的,大牛的救火思路果真比通常人要健壮。

首先看接口是怎么改的:

  1. ns childcomputeScroll中分发滑动给ns parent没有问题(这是关键),可是我要区分开是用户手指移动触发的滑动仍是由惯性触发的滑动(这是锦上添花)
  2. 因而第二版中给全部NestedScrollingChild中滑动相关的 (确切地说是除了「fling相关、滑动开关」外的) 5个方法、全部NestedScrollingParent中滑动相关的 (确切地说是除了「fling相关、获取滑动轴」外的) 5个方法,都增长了一个参数typetype有两个取值表明上述的两种滑动类型:TYPE_TOUCHTYPE_NON_TOUCH
  3. 因此第二版的两个接口没有增删任何方法,只是给10个方法加了个type参数,而且对旧的接口作了个兼容,让它们的typeTYPE_TOUCH

改完了接口固然还要改代码了,Helper 类首先要改

  1. 初版的 NestedScrollingChildHelper 里边原本持有了一个ns parentmNestedScrollingParentTouch,做为绑定关系,第二版 又再加了一个ns parentmNestedScrollingParentNonTouch,为何是两个而不是公用一个,大概是避免对两类滑动的生命周期有过于严格的要求,好比在 NestedScrollView 的实现里,就是先开启TYPE_NON_TOUCH类型的滑动,而后关闭了 TYPE_TOUCH 类型的滑动,若是公用一个 ns parent 域,就作不到这样了
  2. NestedScrollingChildHelper 里边主要就作了这一点额外的改动,其余的改动都是增长参数后的常规变换,NestedScrollingParentHelper 里就更没有特别的变化了

前面在分析初版 Bug 的时候说过「初版 NestedScrolling 机制自己是没有问题的,有问题的是那些系统控件使用这个机制的方式不对」,因此此次改动最大的仍是那些使用了嵌套滑动机制的系统控件了,咱们就以 NestedScrollView 为例来具体看看系统是怎么修复 Bug、建议你们如今应该怎么建立 ns child 角色的。 相同的部分不说了,在调用相关方法的时候要传入 type 也不细说了,主要的变化基本出如今预期的位置:

public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        fling(velocityY); // 华点
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() > 0) {
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
        
        mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0); 
        
        mLastScrollerY = getScrollY();
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        final int x = mScroller.getCurrX();
        final int y = mScroller.getCurrY();
    
        int dy = y - mLastScrollerY;
    
        // Dispatch up to parent
        if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
            dy -= mScrollConsumed[1];
        }
    
        if (dy != 0) {
            final int range = getScrollRange();
            final int oldScrollY = getScrollY();
    
            overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);
    
            final int scrolledDeltaY = getScrollY() - oldScrollY;
            final int unconsumedY = dy - scrolledDeltaY;
    
            if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null, ViewCompat.TYPE_NON_TOUCH)) {
                if (canOverscroll()) showOverScrollEdgeEffect();
            }
        }
    
        ViewCompat.postInvalidateOnAnimation(this);
    } else {
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
}
复制代码

computeScroll()方法的代码贴得比较多,由于它不只是此次Bug修复的主要部分,它仍是下一次Bug修复要改动的部分。 不过其实整个逻辑仍是很简单的,符合预期,简单说明一下:

  1. UP时候作的事情没有变,仍是在这解除了与ns parent的绑定,可是注明了类型是TYPE_TOUCH
  2. flingWithNestedDispatch()这个方法先不说
  3. fling()方法中,调用startNestedScroll()开启了新一轮绑定,不过这时的类型变成了TYPE_NON_TOUCH
  4. 最多的改动是在computeScroll()方法中,但逻辑很清晰:对于每一个dy,都会通过「parent -> child -> parent -> child」这个消费流程,从而实现了惯性连续,解决了 Bug

最后的效果是这样:

另外,从这版开始,View和 ViewGroup 里的 NestedScrolling 机制就没有更新过,一直维持着第一个版本的样子。

3.3.1. 第二个版本的Bug

看上去第二个版本改得很漂亮对吧,但此次改动其实又引入了两个问题,至少有一个算是Bug,另外一个能够说只是交互不够好,不过这个交互不够好的问题引入的缘由却很是使人迷惑。

先说第一个问题:「二倍速」回到小结

  1. 我只知道它正好出如今了NestedScrollView中,RecyclerView等类没有这个问题,我极度怀疑它的引入是由于手滑
  2. 它的现象是这样:当外部 View 不在顶部、内部 View 在顶部时,往下滑动内部 View 而后快速抬起(制造 fling )
    1. 预期效果应该是:外部 View 往下进行惯性滑动
    2. 实际上也大概是这样,但有一点点区别:外部 View 往下滑动的速度会比你预想中要快,大概是两倍的速度(反方向也是同样),以下图
  3. 为何会这样呢?
    1. 你若是把第二版嵌套滑动机制更新的NestedScrollView跟以前的对比,你会很容易发现flingWithNestedDispatch()中(在我贴出来的代码里),fling(velocityY)前的if (canFling)离奇消失了
    2. 但消失不表明是手滑,多是逻辑使然,因而梳理了一下逻辑,这个 if 判断在新的机制中须要去掉吗?额,并不须要。没有了 if 会让外部 View 同时进行两个 fling,实际体验也确实是这样
  4. 因此解决这个问题很简单,直接把 if 判断补上就行了
  5. 不过这个问题在体验上不算明显,不过也不难发现,只是用户可能不知道这是个 Bug 仍是 Feature(233

而后是第二个问题:「空气马达」回到小结

  1. 这个问题确定算 Bug 了,全部的嵌套滑动控件都存在,并且体验很是明显
  2. 这个问题就比较硬核了,真的是 NestedScrolling 机制的问题,确切地说应该叫缺陷,在初版中就存在,只是初版中系统控件的不当的机制使用方式正好不会触发这个问题,可是在第二版后,各个控件改用了新的使用方式,这个问题终于暴露出来了
  3. 它的现象是这样:当外部 View 在顶部、内部 View 也在顶部时,往下滑动内部 View 而后快速抬起(制造 fling ),(目前什么都不会发生,由于都滑到顶了,关键是下一步) 你立刻滑外部 View
    1. 预期应该是:外部 View 往上滚动
    2. 但实际上你会发现:你滑不动它,或是滑上去一点,立刻又下来了,像是有一台无形的马达在跟你的手指较劲(反方向也是同样),如上图
  4. 为何会这样呢?
    1. 其实我开始也不得要领,只好打日志去看到底谁是那个马达,调试了好一会*(当时还闹了个笑话有空再写)*才发现原来马达就是内部 View
    2. 缘由解释起来也是很是简单的:
      1. 先回头看方法flingWithNestedDispatch()中的这段代码:其中的dispatchNestedPreFling()大部分时候会返回false,因而几乎全部的状况下,内部 View 都会经过fling()方法启动本身mScroller这个小马达
      2. 而后在小马达启动后,到computeScroll()方法中,你会看到,(若是你不直接触摸内部View) 除非等到马达本身中止,不然没有外力能让它停下,因而它会一直向外输出dispatchNestedPreScroll()dispatchNestedScroll()
      3. 因此在上面的现象中,即便内外的 View 都在顶部,都没法滑动,内部 View 的小马达还在突突突地工做,只要你把外部 View 滑到不在顶部的位置,它就又会把它给滑下来
      4. 因此其实不须要前面说的「当外部View在顶部、内部View也在顶部时」这种场景(这只是最好复现的场景),当以任何方式开启了内部 View 的小马达后,你又不经过直接触摸内部 View 把它关闭时,都能看到这个问题
  5. 那怎么办?这个问题的症结在哪儿?
    1. 首先内部 View 的小马达是不能废弃的,没有它,怎么突突突地驱动外部 View 呢?
    2. 但也不能任它突突突转个不停,除了用户直接触摸内部 View 让它中止,它还须要有一个中止开关,至少让用户触摸外部 View 的时候也能关闭它,更合理的实现还应该让驱动过程可以反馈,当出现状况没法驱动(好比内外都滑到顶部)时,停下马达
  6. 因此如今须要给驱动过程增长反馈
    1. 前文讲过,这个机制中ns child是主动的一方,ns parent彻底是被动的,ns parent无法主动通知ns child:啊我被摁住了,啊我撞墙了
    2. ns parent并非没办法告知ns child信息,经过方法的返回值和引用类型的参数,ns child仍然能够从ns parent中获取信息
    3. 因此只要给 NestedScrolling 机制加一组方法,让ns child询问ns parent是否可以滑动,问题应该就解决了:若是ns parent滑不动了,ns child本身也滑不动,那就赶忙关闭马达吧,节约能源人人有责
  7. 咱想得确实美,但咱又吃不上G家的饭, NestedScrolling 机制不是你写的,你怎么给整个机制加个方法?好吧,那只能看看这个 NestedScrolling 机制有什么后门能利用了
    1. 一尝试就发现可能有戏,询问ns parent是否可以滑动不是有现成的方法吗?
    2. dispatchNestedPreScroll()会先让ns parentns child以前进行滑动,并且滑动的距离被记录在它的数组参数consumed中,拿到数组中的值ns child就能知道ns parent是否在这时滑动了
    3. dispatchNestedScroll()会让ns parentns child以后进行滑动,它有没有数组参数记录滑动距离,它只有一个返回值记录是否消费了滑动...不对,这个返回值不是记录是否消费滑动用的,它表示的是ns parent是否能顺利联系上,若是能,就返回true,并不关心它是否消费了滑动。在NestedScrollingChild Helper中你也能看到这个逻辑的清晰实现,同时你也会看到在NestedScrollingParent2中它对应的方法是void onNestedScroll(),没有返回值*(考虑过能不能经过dispatchNestedScroll()int[] offsetInWindow没被使用的数组位置来传递信息,结果也由于 parent 中对应的方法不带这个参数而了结;并且ns parent也没法主动解除本身与ns child的绑定,这条路也不通)*。总之,dispatchNestedScroll()没法让ns child得知ns parent对事件的消费状况,此路不通
    4. (其实以后经过把dispatchNestedScroll()的消费结果直接放在ns child的 View 中,用这个后门解决了Bug,但这种方式使用的局限比较大,并且下面要介绍的最新的第三版已经修复了这个问题,我就很少写了)

3.4. 第三个版本,2018年11月

第二版的 Bug 虽然比初版的严重,但好像没有太多人知道,可能这种使用场景仍是没有那么多。 不过期隔一年多,Google 终因而意识到了这个问题,在最近也就是2018年11月5日androidx.core 1.1.0-alpha01更新中,给出了最新的修复——NestedScrollingChild3NestedScrollingParent3,以及一系列系统组件也陆续进行了更新。

这就是第三个版本的 NestedScrolling 机制了,这个版本确实对上面两个 Bug 进行了处理,但惋惜的是,第二个 Bug 并无修理干净 (为 Google 大佬献上一首つづく,期待第四版) (在本文快要完成的时候正好看到新一任消防员在18年12月3日发了条 twitter 说已经发布了第三版,结果评论区你们已经在欢乐地期待 NestedScrollingChild42 NestedScrollingChildX NestedScrollingParentXSMax NestedScrollingParentFinalFinalFinal NestedScrollingParent2019 了 )

继续来看看在这个版本中,大佬是怎么救火的

照例先看接口,一看接口的改动你可能就笑了,真的是哪里不通改哪里

  1. 在接口NestedScrollingChild3中,没有增长方法,只是给dispatchNestedScroll方法增长了一个参数int[] consumed,而且把它的boolean返回值改为了void,有了能获取更详细信息的途径,天然就不须要这个boolean
  2. 接口NestedScrollingParent3一样只是改了一个方法,给onNestedScroll增长了int[] consumed参数(它返回值就是 void,没变)

下面是NestedScrollingChild3中的对比:

// 2
boolean dispatchNestedScroll( int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type );
    
// 3
void dispatchNestedScroll( int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, @NestedScrollType int type, @NonNull int[] consumed // 这个 );
复制代码

再看下 Helper ,NestedScrollingChildHelper除了适配新的接口基本没有改动,NestedScrollingParentHelper也只是加强了一点逻辑的严谨性(大概是被review了233)

最后看用法,仍是经过咱们的老朋友NestedScrollView来看,改动部分跟预期基本一致:

@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
            
    final int oldScrollY = getScrollY();
    scrollBy(0, dyUnconsumed);
    final int myConsumed = getScrollY() - oldScrollY;
    
    if (consumed != null) consumed[1] += myConsumed; // 就加了这一句
    
    final int myUnconsumed = dyUnconsumed - myConsumed;
    mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
    
// ---
    
// onTouchEvent 中逻辑没有变化
private void flingWithNestedDispatch(int velocityY) {
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, true);
        fling(velocityY); // fling 中的逻辑没有变化
    }
}
    
@Override
public void computeScroll() {
    if (mScroller.isFinished()) return;
    mScroller.computeScrollOffset();
    final int y = mScroller.getCurrY();
    
    int unconsumed = y - mLastScrollerY;
    
    // Nested Scrolling Pre Pass
    mScrollConsumed[1] = 0;
    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;
        dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset, ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
        unconsumed -= mScrollConsumed[1];
    }
    
    // 处理最后还有 unconsumed 的状况
    if (unconsumed != 0) {
        if (canOverscroll()) showOverScrollEdgeEffect();
    
        mScroller.abortAnimation(); // 关停小马达
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
    }
    
    if (!mScroller.isFinished()) ViewCompat.postInvalidateOnAnimation(this);
}
复制代码

修改最多的仍是computeScroll(),不过其余地方也有些变化,简单说明一下:

  1. 由于onNestedScroll()增长了记录距离消耗的参数,因此ns parent就须要把这个数据记录上而且继续传递给本身的ns parent
  2. flingWithNestedDispatch()是以前有蜜汁 Bug 的方法,原本个人预期是恢复初版的写法,也就是把fling(velocityY)前的if (canFling)加回来,结果这下倒好,连canFling也不判断了,dispatchNestedFling(0, velocityY, true)直接传truefling(velocityY)始终调用。这意味着什么呢?须要结合大部分View的写法来看
    1. 搜索API 28的代码你就会看到:
      1. 对于onNestedPreFling()方法,除了ResolverDrawerLayout会在某些状况下消费fling并返回true,以及CoordinatorLayout会象征性地问一遍本身孩子们的Behavior,其它的写法都是直接返回false
      2. 对于onNestedFling(boolean consumed)方法,全部的写法都是,只要consumedtrue,就什么都不会作,这种作法也很是天然
    2. 因此当前的现状是:绝大部分状况下,内部 View 的 fling 小马达都会启动,外部 View 都不会消费内部 View 产生的 fling。这就表明着:惯性的协做机制彻底被滑动的协做机制取代了。这也是我不推荐给初学者介绍这组没什么用的接口的缘由
    3. 但固然,即便名不副实,但若是你真的有特殊需求须要使用到 fling 的传递机制,你也是能够用的
  3. 最后来看computeScroll(),它基本把咱们在讨论怎么修复第二版中 Bug 时的思路实现了:由于能从dispatchNestedPreScroll()dispatchNestedScroll()得知ns parent消耗了多少这一次分发出去的滑动距离,同时也有本身消耗了多少,二者一合计,若是还有没消耗的滑动距离,那确定不管内外都滑到头了,因而就该果断就把小马达关停

如今的效果是这样的,能看到第二版中的Bug确实解决了

3.4.1. 第三个版本的Bug

那么为何我还说第二个 Bug 没有解决完全呢?

  1. 对比代码容易看到,第三版中DOWN事件的处理相对第二版没有变化,它没有加入触摸外部 View 后关闭内部 View 马达的机制,更确切地说是没有加入「触摸外部 View 后阻止对内部 View 传递过来的滑动进行消费的机制」
  2. 因此只有外部 View 滑动到尽头的时候才能关闭马达,外部 View 无法给内部 View 反馈本身被摁住了

虽然现象与「空气马达」相似,但仍是按照惯例给它也起个好听的新名字,就叫:...「摁不住」回到小结

实际体验跟分析结果同样这样,当经过滑动内部 View 触发外部 View 滑动时,你没法经过触摸外部 View 把它停下来,外部 View 比较长的时候容易复现,以下图(换了一个方向)

-w200

不过这个问题只有能够响应触摸的ns parent须要考虑,能够响应触摸的ns parent主要就是NestedScrollView了,因此这个问题主要仍是NestedScrollView的问题。并且它也跟机制无关,只是NestedScrollView的用法不对,因此前面说的会有第四版 NestedScrolling 机制可能性也不大,大概只会给NestedScrollView上个普通的更新吧(顺手给 Google 大佬递了瓶 可乐

而这个问题本身改也很是好改,只须要在DOWN事件后能给ns child反馈本身被摁住了就行,能够用反射,或是直接把NestedScrollView挪出来改,关键代码以下

private boolean mIsBeingTouched = false;

@Override
public boolean onTouchEvent(MotionEvent ev) {
    switch (ev.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            mIsBeingTouched = true;
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            mIsBeingTouched = false;
            break;
    }

    return super.onTouchEvent(ev);
}

private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
    final int oldScrollY = getScrollY();
    if (!mIsBeingTouched) scrollBy(0, dyUnconsumed); // 只改了这一句
    final int myConsumed = getScrollY() - oldScrollY;

    if (consumed != null) {
        consumed[1] += myConsumed;
    }
    final int myUnconsumed = dyUnconsumed - myConsumed;

    childHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
复制代码

我把用反射改好的放在这里了,你也能够直接使用 改完以后效果以下:

3.5. 小结

历史终于讲完了,小结一下回去看详细历史

  1. 2014年9月,Google 在Android 5.0( API 21)中的 View 和 ViewGroup 中加入了第一个版本的 NestedScrolling 机制,此时可以经过启用嵌套滑动,让嵌套的ScrollView不出现交互问题,但这个机制只有 API 21 以上才能使用
  2. 2015年4月,Google 重构了第一个版本的 NestedScrolling 机制,逻辑没有变化,可是把它从 View 和 ViewGroup 中剥离,获得了两个接口(NestedScrollingChildNestedScrollingParent)和两个 Helper (NestedScrollingChildHelperNestedScrollingParentHelper),而且用这套新的机制重写了一个默认启用嵌套滑动的NestedScrollView,并把它们都放入了Revision 22.1.0v4 support library,让低版本的系统也能使用嵌套滑动机制,不过此时的初版机制有「惯性不连续」的 Bug
  3. 2017年9月,Google 在Revision 26.1.0v4 support library中发布了第二个版本的 NestedScrolling 机制,增长了接口NestedScrollingChild2NestedScrollingParent2,主要是给本来滑动相关的方法增长了一个参数type,表示了两种滑动类型TYPE_TOUCHTYPE_NON_TOUCH。而且使用新的机制重写了嵌套滑动相关的控件。此次更新解决了第一个版本中「惯性不连续」的Bug,但也引入了新的Bug:「二倍速」(仅NestedScrollView)和「空气马达」
  4. 2018年11月,Google 给已经并入AndroidX 家族的 NestedScrolling 机制更新了第三个版本,具体版本是androidx.core 1.1.0-alpha01,增长了接口NestedScrollingChild3NestedScrollingParent3,改动只是给原来的dispatchNestedScroll()onNestedScroll()增长了int[] consumed参数。而且后续把嵌套滑动相关的控件用新机制进行了重写。此次更新解决了第二个版本中 NestedScrollView的「二倍速」Bug,同时指望解决「空气马达」Bug,可是没有解决完全,还遗留了「摁不住」Bug

因此前面的问题你们应该都有了答案

  1. 使用接口和 Helper 是为了兼容低版本和容易升级,并非 NestedScrolling 机制用起来最方便的样子。因此为了便于理解,我就直接说调用 View 和 ViewGroup 的方法,但真正用的时候你最好仍是在 Helper 的帮助下实现它最新的接口,而后再调用你实现的这些方法,由于 View 和 ViewGroup 的方法对 API 的版本要求高,本身的版本又很低。这点使用上的变化比较简单,由于方法名跟 View 和 ViewGroup 中的都同样,Helper 的使用也很直接,就不举例子了。
  2. 经常使用的方法也就是这9个了,剩下的8个不用急着去了解,其中 fling 相关方法有点凉凉的味道。而后第二版机制和第三版机制并无增长新的方法,机制的整体设计没有大的变化。
  3. 第二版和第三版都是在修 Bug ,恩,还没修完。

(转载请注明做者:RubiTree,地址:blog.rubitree.com

4. 实践

第二节中其实已经讲过了实践,而且提供了实现 ns child 的模板。 这里我准备用刚发现的一个更有实际意义的例子来说一下 ns parent 的实现,以及系统库中 ns child 的几个细节。

4.1. 选题:悬停布局

这个例子是「悬停布局」 你叫它粘性布局、悬浮布局、折叠布局都行,总之它理想的效果应该是这样:

用文字描述是这样:

  1. 页面内容分为 Header、悬停区(通常会是 TabLayout)和内容区,其中内容区能够左右滑动,有多个 Tab 页,并且每一个 Tab 页是容许上下滑动的
  2. 用户向上滑动时,先折叠 Header,当 Header 所有折叠收起后,悬停区悬停不动,内容区向上滑动
  3. 用户向下滑动时,先把内容区向下滑动,而后展开 Header,悬停区顺势下移
  4. 其中内容区的滑动和 Header 的收起展开在用户连续滑动时应该表现为连续的,甚至在用户滑动中快速抬起时,滑动的惯性也须要在两个动做间保持连续

在当前这个时间点(2019.1.13),这个例子还有很多实际意义,由于它虽然是比较常见的一个交互效果,但如今市场上的主流APP,竟然是这样的...(饿了么v8.9.3)

这样的...(知乎v5.32.2)
这样的...(腾讯课堂v3.24.0.5)
这样的...(哔哩哔哩v5.36.0)

先无论它们是否是用 Native 实现的,只看实现的效果

  1. 其中哔哩哔哩的视频详情页和美团(没有贴图)算是作得最好的,滑动连续惯性也连续,但也存在一个小瑕疵:在 Header 部分上下滑动时你能够同时进行左右滑动,容易误操做
  2. 而腾讯课堂的问题是最广泛的:惯性不连续
  3. 最奇葩是饿了么的店铺首页和知乎的 Live 详情页,都是创收的页面啊,竟然能自带鬼畜,好吧,也是心大

其余还有一些千奇百怪的 Bug 就不举例了。 因此,就让咱们来看看,这个功能实现起来是否是真有那么难。

4.2. 需求分析

若是内容区只有一个 Tab 页,一种简单直接的实现思路是:页面整个就是一个滑动控件,悬停区域会在滑动过程当中不断调整本身的位置,实现悬停的效果。 它的实现很是简单,效果也彻底符合要求,不举例了,能够本身试试。

但这里的需求是有多个 Tab 页,它用一整个滑动控件的思路是没法实现的,须要用多个滑动控件配合实现

  1. 先看看有哪些滑动控件:每一个 Tab 页内确定是独立的滑动控件,要实现 Header 的展开收起,能够把整个容器做为一个滑动控件
  2. 这就变成了一个外部滑动控件和一组内部滑动控件进行配合,看上去有点复杂,但实际上在一次用户滑动过程当中,只有一个外部滑动控件和一个内部滑动控件进行配合
  3. 配合过程是这样的(能够回头看下前面的理想效果动态图):
    1. 用户上滑,外部滑动控件先消费事件进行上滑,直到滑动到 Header 的底部,外部滑动控件滑动结束,把滑动事件交给内部滑动控件,内部滑动控件继续滑动
    2. 用户下滑,内部滑动控件先消费事件进行下滑,直到滑动到内部控件的顶部,内部滑动控件滑动结束,把滑动事件交给外部滑动控件,外部滑动控件继续滑动
    3. 当用户滑动过程当中快速抬起进行惯性滑动的时候,也须要遵循上面的配合规律

在了解 NestedScrolling 机制以前,你可能以为这个需求不太对劲,确实,从大的角度看,用户的一次触摸操做,却让多个 View 前后对其进行消费,它违背了事件分发的原则,也超出了 Android 触摸事件处理框架提供的功能:父 View 没用完的事件子 View 继续用,子 View 没用完的事件父 View 继续用

但具体到这个需求中

  1. 首先,两个滑动控件配合消费事件的指望效果是,与内容区只有一个 Tab 页同样,让用户感知上认为本身在滑动一整个控件,只是其中某个部分会悬停,它并无违背用户的直觉。因此,通过精心设计,多个View 消费同一个事件流也是能够符合用户直觉的。在这个领域表现最突出的就是CoordinatorLayout了,它就是用来帮助开发者去实现他们精心设计的多个 View 消费同一个事件流的效果的
  2. 而后,因为滑动反馈的简单性,让多个滑动控件的滑动进行配合也是可以作到的。你能够本身实现,也能够借助咱们已经熟悉的NestedScrolling机制实现。另外CoordinatorLayout让多个滑动控件配合对同一个事件流进行消费也是利用NestedScrolling机制

OK,既然需求提得没问题,并且咱们也能实现,那下面就来看看具体要怎么实现。

可能有同窗立刻就举手了:我知道我知道,用CoordinatorLayout! 对,当前这个效果最多见的实现方式就是使用基于CoordinatorLayoutAppBarLayout全家桶,这是它的自带效果,经过简单配置就能实现,并且还附送更多其余特效,很是酷炫,前面看到的效果比较好的哔哩哔哩视频详情页就是用它实现的。 而AppBarLayout实现这个功能的方式实际上是也使用了CoordinatorLayout提供的NestedScrolling机制(虽然实现的具体方法跟上面的分析有些区别,但并不重要,感兴趣的同窗能够看AppBarLayoutBehavior),若是你嫌弃AppBarLayout全家桶过重了,只想单独实现悬停功能,如前文所述,你也能够直接使用NestedScrolling机制去实现。

这里就直接使用NestedScrolling机制来实现出一个相似哔哩哔哩这样正常一些的悬停布局。

4.3. 需求实现

NestedScrolling机制一想,你会发现实现起来很是简单,上面的分析过程在机制中直接就有对应的接口,咱们只要实现一个符合要求的 ns parent 就行了,NestedScrolling机制会自动管理 ns parentns child 的绑定和 scroll 的传递,即便 ns childns parent 相隔好几层 View。

我把要实现的 ns parent 叫作 SuspendedLayout ,其中的关键代码以下,它剩下的代码以及布局和页面代码就不写出来了,能够在这里查看(简单把第一个 child view 做为 Header,第二个 child view 会天然悬停)。

override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
    if (dyUnconsumed < 0) scrollDown(dyUnconsumed, consumed)
}

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy > 0) scrollUp(dy, consumed)
}

/*-------------------------------------------------*/

private fun scrollDown(dyUnconsumed: Int, consumed: IntArray?) {
    val oldScrollY = scrollY
    scrollBy(0, dyUnconsumed)
    val myConsumed = scrollY - oldScrollY

    if (consumed != null) {
        consumed[1] += myConsumed
    }
}

private fun scrollUp(dy: Int, consumed: IntArray) {
    val oldScrollY = scrollY
    scrollBy(0, dy)
    consumed[1] = scrollY - oldScrollY
}

override fun scrollTo(x: Int, y: Int) {
    val validY = MathUtils.clamp(y, 0, headerHeight)
    super.scrollTo(x, validY)
}
复制代码

这么快就实现了,效果很是完美,与哔哩哔哩几乎同样:

4.4. 优化误操做问题

但效果同样好也同样坏,哔哩哔哩的那个容易误操做的问题这里也有。 先看看为何会出现这样的问题?

  1. 从问题表现上很容易找到线索,确定是在上滑过程当中被 ViewPager 拦截了事件,也就是 ns child 没有及时「申请外部不拦截事件流」,因而到 NestScrollViewRecyclerView 中查看,问题其实就出在前面描述的ns childonTouchEvent() 中的逻辑
  2. 由于 ns child 会在判断出用户在滑动后「申请外部不拦截事件流」,但 onTouchEvent() 中又在判断出用户在滑动前就把滑动用 dispatchNestedPreScroll() 方法传递给了 ns parent,因而你就会看到,明明已经识别出我在上下滑动ns child了,并且已经滑了一段距离,竟然会突然切换成滑动 ViewPager

因此这个问题要怎么修复呢?

  1. 直接修改源码确定是解决办法
    1. 我尝试了把NestScrollView代码拷贝出来,并把其中的 dispatchNestedPreScroll() 方法放在判断出滑动以后进行调用,确实解决了问题
  2. 但能不能不去拷贝源码呢?
    1. 也是能够的,只要能及时调用parent.requestDisallowInterceptTouchEvent(true)便可,完整代码见此,其中关键代码以下:
private int downScreenOffset = 0;
private int[] offsetInWindow = new int[2];

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
        downScreenOffset = getOffsetY();
    }

    if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) {
        final int activePointerIndex = ev.findPointerIndex(getInt("mActivePointerId"));
        if (activePointerIndex != -1) {
            final int y = (int) ev.getY(activePointerIndex);
            int mLastMotionY = getInt("mLastMotionY");
            int deltaY = mLastMotionY - y - (getOffsetY() - downScreenOffset);

            if (!getBoolean("mIsBeingDragged") && Math.abs(deltaY) > getInt("mTouchSlop")) {
                final ViewParent parent = getParent();
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
                setBoolean("mIsBeingDragged", true);
            }
        }
    }

    return super.onTouchEvent(ev);
}

private int getOffsetY() {
    getLocationInWindow(offsetInWindow);
    return offsetInWindow[1];
}
复制代码

这里有个细节值得一提:在计算deltaY时不仅是用mLastMotionY - y,还减去了(getOffsetY() - downScreenOffset),这里的offsetInWindow其实也出如今 NestedScrolling 机制里的dispatchNestedScroll()等接口中

  1. offsetInWindow的做用很是关键,由于当 ns child 驱动 ns parent 滑动时,ns child 其实也在移动,此时ns child中获取到的手指触发的motion eventxy值是相对ns child的,因此此时若是直接使用y值,你会发现y值几乎没有变化,这样算到的deltaY也会没有变化,因此须要再获取ns child相对窗口的偏移,把它算入deltaY,才能获得你真正须要的deltaY
  2. ViewPager为何会在竖直滑动那么远以后还能对横滑进行拦截,也是这个缘由,它获取到的deltaY其实很小

改完以后的效果以下,能看到解决了问题:

RecyclerView等其余的ns child若是须要的话,也能够作相似的改动(不过这里的反射代码对性能有所影响,建议实现上作一些优化)

(转载请注明做者:RubiTree,地址:blog.rubitree.com

5. 总结

若是你没有跳过地看到这里,关于 NestedScrolling 机制,我相信如今不管是使用、仍是原理、甚至八卦历史,你都了解得一清二楚了,不然我只能怀疑你的个人语文老师表达水平了。

而关于代码的设计,你大概也能学到一点,Google 工程师三入火场英勇救火的身影应该给你留下了深入的印象。

最后关于使用多说两句:

  1. 若是你须要目前最好的嵌套滑动体验,无论是直接用系统 View 仍是自定义 View ,直接用最新的 AndroidX 吧,而且自定义的时候注意使用3系列
  2. 若是你的项目暂时不方便切换 AndroidX,那么就升级到最新的 v4 吧,注意自定义的时候用2系列
  3. 若是你的项目追求极致体验,并且正好用到了嵌套的NestedScrollView,认为第三版的 Bug 也会影响到你宝贵而敏感的用户,那不如试试 implementation 个人项目 :D

最后的最后,G 家的消防员都有顾不过来的时候,更况且是本菜鸡,本文内容确定会有疏漏和不当之处,欢迎你们提 issue 啦~

(以为写得好的话,不妨点个赞再走呀~ 给做者一点继续写下去的动力)

相关文章
相关标签/搜索