<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

  <title><![CDATA[RubiTree's Blog]]></title>
  <link href="http://blog.rubitree.com/atom.xml" rel="self"/>
  <link href="http://blog.rubitree.com/"/>
  <updated>2019-01-28T19:40:28+08:00</updated>
  <id>http://blog.rubitree.com/</id>
  <author>
    <name><![CDATA[]]></name>
    
  </author>
  <generator uri="http://www.coderforart.com/">CoderForArt</generator>

  
  <entry>
    <title type="html"><![CDATA[【透镜系列】看穿 > NestedScrolling 机制 >]]></title>
    <link href="http://blog.rubitree.com/15467469615604.html"/>
    <updated>2019-01-06T11:56:01+08:00</updated>
    <id>http://blog.rubitree.com/15467469615604.html</id>
    <content type="html"><![CDATA[
<p><img src="media/15467469615604/15468896445806.jpg" alt=""/></p>

<span id="more"></span><!-- more -->

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em><br/>
（图片加载速度稍慢，还请看官耐心等待~）<br/>
（博客搭得有点简陋，可以点击 <a href="https://juejin.im/user/5c3c83bd6fb9a049b5070f9e/posts">&gt;&gt; RubiTree 的掘金专栏 &lt;&lt;</a> 获得更好的浏览体验哦~）</p>

<hr/>

<p></br><br/>
目录：</p>

<ul>
<li>
<a href="#toc_0">1. 嵌套同向滑动</a>
<ul>
<li>
<a href="#toc_1">1.1. 嵌套同向滑动的问题</a>
</li>
<li>
<a href="#toc_2">1.2. 分析问题原因</a>
</li>
<li>
<a href="#toc_3">1.3. 尝试解决问题</a>
</li>
<li>
<a href="#toc_4">1.4. 第一次优化</a>
</li>
<li>
<a href="#toc_5">1.5. 第二次优化</a>
</li>
<li>
<a href="#toc_6">1.6. 小结</a>
</li>
</ul>
</li>
<li>
<a href="#toc_7">2. NestedScrolling 机制</a>
<ul>
<li>
<a href="#toc_8">2.1. 原理</a>
</li>
<li>
<a href="#toc_9">2.2. 使用</a>
</li>
</ul>
</li>
<li>
<a href="#toc_10">3. 历史的消防车滚滚向前</a>
<ul>
<li>
<a href="#toc_11">3.1. 第一个版本，2014年9月</a>
</li>
<li>
<a href="#toc_12">3.2. 重构第一个版本，2015年4月</a>
<ul>
<li>
<a href="#toc_13">3.2.1. 第一个版本的Bug</a>
</li>
</ul>
</li>
<li>
<a href="#toc_14">3.3. 第二个版本，2017年9月</a>
<ul>
<li>
<a href="#toc_15">3.3.1. 第二个版本的Bug</a>
</li>
</ul>
</li>
<li>
<a href="#toc_16">3.4. 第三个版本，2018年11月</a>
<ul>
<li>
<a href="#toc_17">3.4.1. 第三个版本的Bug</a>
</li>
</ul>
</li>
<li>
<a href="#toc_18">3.5. 小结</a>
</li>
</ul>
</li>
<li>
<a href="#toc_19">4. 实践</a>
<ul>
<li>
<a href="#toc_20">4.1. 选题：悬停布局</a>
</li>
<li>
<a href="#toc_21">4.2. 需求分析</a>
</li>
<li>
<a href="#toc_22">4.3. 需求实现</a>
</li>
<li>
<a href="#toc_23">4.4. 优化误操作问题</a>
</li>
</ul>
</li>
<li>
<a href="#toc_24">5. 总结</a>
</li>
</ul>


<hr/>

<p></br></p>

<p>NestedScrolling 机制翻译过来叫嵌套滑动机制（本文将混用），它提供了一种优雅解决嵌套滑动问题的方案，具体是什么方案呢？我们从嵌套的同向滑动说起。</p>

<p>（文章有一点点长，但一定是字有所值的，把结尾的一句话搬到前面：如果你没有跳过地看完本文，关于 NestedScrolling 机制，我相信现在无论是原理、还是使用 <em>（此处有彩蛋）</em> 、甚至<del>八卦</del>历史，你都了解得一清二楚了，否则我只能怀疑<del>你的</del>我的语文<del>老师</del>表达水平了）</p>

<h2 id="toc_0">1. 嵌套同向滑动</h2>

<h3 id="toc_1">1.1. 嵌套同向滑动的问题</h3>

<p>所谓嵌套同向滑动，就是指这样一种情况：两个可滑动的View内外嵌套，而且它们的滑动方向是相同的。<br/>
<img src="media/15467469615604/15468896135503.jpg" alt="" style="width:350px;"/></p>

<p>这种情况如果使用一般的处理方式，会出现交互问题，比如使用两个<code>ScrollView</code>进行布局，你会发现，触摸着内部的<code>ScrollView</code>进行滑动，它是滑不动的<em>（不考虑后来 Google 给它加的<code>NestedScroll</code>开关）</em>：<br/>
<img src="media/15467469615604/2019-01-03_21-05-19%20-1-.gif" alt="" style="width:200px;"/></p>

<h3 id="toc_2">1.2. 分析问题原因</h3>

<p>（温馨提示：本文涉及事件分发的内容比较多，建议对事件分发不太熟悉的同学先阅读另一篇透镜<a href="http://blog.rubitree.com/15467469615604.html">《看穿 &gt; 触摸事件分发》</a>）</p>

<p>如果你熟悉 Android 的触摸事件分发机制，那么原因很好理解：两个<code>ScrollView</code>嵌套时，滑动距离终于达到滑动手势判定阈值（<code>mTouchSlop</code>）的这个<code>MOVE</code>事件，会先经过父 View 的<code>onInterceptTouchEvent()</code>方法，父 View 于是直接把事件拦截，子 View 的<code>onTouchEvent()</code>方法里虽然也会在判定滑动距离足够后调用<code>requestDisallowInterceptTouchEvent(true)</code>，但始终要晚一步。</p>

<p>而这个效果显然是不符合用户直觉的<br/>
那用户希望看到什么效果呢？</p>

<ol>
<li>大部分时候，用户希望看到：当手指触摸内部<code>ScrollView</code>进行滑动时，能先滑动内部的<code>ScrollView</code>，只有当内部的<code>ScrollView</code>滑动到尽头时，才滑动外部的<code>ScrollView</code></li>
</ol>

<p>这看上去非常自然，也跟触摸事件的处理方式一致，但相比触摸事件的处理，要在滑动时实现同样的效果却会困难很多</p>

<ol>
<li>因为滑动动作不能立刻识别出来，它的处理本身就需要通过事件拦截机制进行，而事件拦截机制实质上跟<a href="http://blog.rubitree.com/15467469615604.html">《看穿 &gt; 触摸事件分发》</a>中第一次试造的轮子一样，只是单向的，而且方向从外到内，所以无法做到：先让内部拦截滑动，内部不拦截滑动时，再在让外部拦截滑动</li>
</ol>

<p>那能不能把事件拦截机制变成双向的呢？不是不行，但这显然违背了拦截机制的初衷，而且它很快会发展成无限递归的：双向的事件拦截机制本身是否也需要一个拦截机制呢？于是有了拦截的拦截，然后再有拦截的拦截的拦截...<br/>
<img src="media/15467469615604/15467627036503.jpg" alt="" style="width:150px;"/></p>

<h3 id="toc_3">1.3. 尝试解决问题</h3>

<p>换一个更直接的思路，如果我们的需求始终是内部滑动优先，那是否可以让外部 View「拦截滑动的判定条件」比内部 View「申请外部不拦截的判定条件」更严格，从而让滑动距离每次都先达到「申请外部不拦截的判定条件」，子 View 就能够在父 View 拦截事件前申请外部不拦截了。<br/>
能看到在<code>ScrollView</code>中，「拦截滑动的判定条件」和「申请外部不拦截的判定条件」都是<code>Math.abs(deltaY) &gt; mTouchSlop</code>，我们只需要增大「拦截滑动的判定条件」时的<code>mTouchSlop</code>就行了。</p>

<p>但实际上这样做并不好，因为<code>mTouchSlop</code>到底应该增加多少，是件不确定的事情，手指滑动的快慢和屏幕的分辨率可能都会对它有影响。<br/>
所以可以换一种实现，那就是让第一次「拦截滑动的判定条件」成立时，先不进行拦截，如果内部没有申请外部不拦截，第二次条件成立时，再进行拦截，这样也同样实现了开始的思路。<br/>
于是继承 <code>ScrollView</code>，覆写它的<code>onInterceptTouchEvent()</code>：</p>

<pre><code class="language-kotlin">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 &amp;&amp; isFirstIntercept) {
            isFirstIntercept = false
            return false
        }

        return result
    }
}    
</code></pre>

<p>它的效果是这样，能看到确实实现了让内部先获取事件：<br/>
<img src="media/15467469615604/2019-01-03_21-09-34%20-1-.gif" alt="" style="width:200px;"/></p>

<h3 id="toc_4">1.4. 第一次优化</h3>

<p>但我们希望体验能更好一点，从上图能看到，内部即使在自己无法滑动的时候，也会对事件进行拦截，无法通过滑动内部来让外部滑动。其实内部应该在自己无法滑动的时候，直接在<code>onTouchEvent()</code>返回<code>false</code>，不触发「申请外部不拦截的判定条件」，就能让内外都有机会滑动。<br/>
这个要求非常通用而且合理，在<code>SimpleNestedScrollView</code>基础上进行简单修改，<strong>加上</strong>以下代码：</p>

<pre><code class="language-kotlin">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(&quot;mLastMotionY&quot;)
            if (Math.abs(offsetY) &gt; getInt(&quot;mTouchSlop&quot;)) { // 滑动距离足够判断滑动方向是上还是下后
                // 判断自己是否能在对应滑动方向上进行滑动（不能则返回false）
                if ((offsetY &gt; 0 &amp;&amp; isScrollToTop()) || (offsetY &lt; 0 &amp;&amp; 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
}
</code></pre>

<ol>
<li>其中<code>getInt(&quot;mLastMotionY&quot;)</code>和<code>getInt(&quot;mTouchSlop&quot;)</code>为反射代码，获取私有的<code>mLastMotionY</code>和<code>mTouchSlop</code>属性</li>
<li>这段代码省略了多点触控情况的判断</li>
</ol>

<p>运行效果如下：<br/>
<img src="media/15467469615604/2019-01-03_21-30-03%20-1-.gif" alt="" style="width:200px;"/></p>

<p>这样就完成了对嵌套滑动View最基本的需求：大家都能滑了。</p>

<p>后来我发现了一种更野的路子，不用小心翼翼地让改动尽量小，既然内部优先，完全可以让内部的<code>ScrollView</code>在<code>DOWN</code>事件的时候就申请外部不拦截，然后在滑动一段距离后，如果判断自己在该滑动方向无法滑动，再取消对外部的拦截限制，思路是类似的但代码更简单。</p>

<pre><code class="language-kotlin">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(&quot;mLastMotionY&quot;)

            if (Math.abs(offsetY) &gt; getInt(&quot;mTouchSlop&quot;)) {
                if ((offsetY &gt; 0 &amp;&amp; isScrollToTop()) || (offsetY &lt; 0 &amp;&amp; isScrollToBottom())) {
                    parent.requestDisallowInterceptTouchEvent(false)
                }
            }
        }
        
        return super.dispatchTouchEvent(ev)
    }
}
</code></pre>

<p>运行的效果跟上面是一样的，不重复贴图了。</p>

<h3 id="toc_5">1.5. 第二次优化</h3>

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

<p>由于滑动这个交互的特殊性，我们可以在外部对它进行操作，所以连续滑动的实现非常简单，只要重写<code>scrollBy</code>就好了，所以在已有代码的基础上再加上下面的代码（上面的两种思路都是加一样的代码）：</p>

<pre><code class="language-kotlin">override fun scrollBy(x: Int, y: Int) {
    if ((y &gt; 0 &amp;&amp; isScrollToTop()) || (y &lt; 0 &amp;&amp; isScrollToBottom())) {
        (parent as View).scrollBy(x, y)
    } else {
        super.scrollBy(x, y)
    }
}
</code></pre>

<p>效果如下：<br/>
<img src="media/15467469615604/2019-01-03_22-54-04%20-1-.gif" alt="" style="width:200px;"/></p>

<p>而惯性滑动的实现就会相对复杂一点，得对<code>computeScroll()</code>方法下手，要做的修改会多一些，这里暂时不去实现了，但做肯定是没问题的。</p>

<h3 id="toc_6">1.6. 小结</h3>

<p>到这里我们对嵌套滑动交互的理解基本已经非常通透了，知道了让我们自己实现也就那么回事，主要需要解决下面几个问题：</p>

<ol>
<li>在内部 View 可以滑动的时候，阻止外部 View 拦截滑动事件，先滑动内部 View</li>
<li>在用户一次滑动操作中，当内部 View 滑动到终点时，切换滑动对象为外部 View，让用户能够连续滑动</li>
<li>在用户快速抬起触发的惯性滑动中，当内部 View 滑动到终点时，切换滑动对象为外部 View，让惯性能够连续</li>
</ol>

<p>这时就可以<del>（终于可以了）</del>来看看看系统提供的 NestedScrolling 机制是怎么完成嵌套滑动需求的，跟我们的实现相比，有什么区别，是更好还是更好？</p>

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<h2 id="toc_7">2. NestedScrolling 机制</h2>

<h3 id="toc_8">2.1. 原理</h3>

<p>与我们不同，我们只考虑了给<code>ScrollView</code>增加支持嵌套滑动的特性，但系统开发者需要考虑给所有有滑动交互的 View 增加这个特性，所以一个直接的思路是在 View 里加入这个机制。</p>

<p>那么要怎么加，加哪些东西呢？</p>

<ol>
<li>进一步梳理前面要解决的问题，在嵌套滑动中，是能明确区分两类作用对象的：一个是内部 View，一个是外部 View。而且它们的主被动关系也非常明确：因为内部 View 离手指更近，我们肯定希望它能优先消费事件，但我们同时还希望在某些情况下事件能在内部不消耗的时候给外部消耗，这当然也是让内部来控制，所以<span id="ns_roles"><strong>内部是主动，外部是被动</strong></span><em>（<a href="#bug2_2">回到空气马达</a>）</em></li>
<li>由此整个嵌套滑动的过程可以认为是这样的：触摸事件交给内部 View 进行消费，内部 View 执行相关逻辑，在合适的时候对外部 View 进行一定的控制，两者配合实现嵌套滑动</li>
<li>这就包括了两部分逻辑：

<ol>
<li>内部 View 中的主动逻辑：需要主动阻止外部 View 拦截事件，需要自己进行滑动，并在合适的时候让外部 View 配合进行剩下的滑动

<ol>
<li>这部分是核心内容，前面我们自己实现的也是这部分内容</li>
</ol></li>
<li>外部 View 中的被动逻辑

<ol>
<li>基本就是配合行动了，这部分逻辑不多</li>
</ol></li>
</ol></li>
<li>由于<code>View</code>里是不能放其他<code>View</code>的，它只能是内部的、主动的角色，而<code>ViewGroup</code>既可以放在另一<code>ViewGroup</code>里，它里边也可以放其他的<code>View</code>，所以它可以是内部的也可以是外部的角色</li>
<li>这正好符合<code>View</code>和<code>ViewGroup</code>的继承关系，所以一个很自然的设计是：在<code>View</code>中加入主动逻辑，在<code>ViewGroup</code>中加入被动逻辑</li>
</ol>

<p>因为不是每个<code>View</code>和<code>ViewGroup</code>都能够滑动，滑动只是众多交互中的一种，<code>View</code>和<code>ViewGroup</code>不可能直接把所有事情都做了然后告诉你：Android 支持嵌套滑动了哦~ 所以 Google 加入的这些逻辑其实都是帮助方法，相关的<code>View</code>需要选择在合适的时候进行调用，最后才能实现嵌套滑动的效果。</p>

<p>先不说加了哪些方法，先说 Google 希望能帮助你实现一个什么样的嵌套滑动效果：</p>

<ol>
<li>从逻辑上区分嵌套滑动中的两个角色：<code>ns child</code>和<code>ns parent</code>，对应了上面的内部 View 和外部 View

<ol>
<li><em>注：1）这里我用「ns」表示<code>nested scroll</code>的缩写；2）为什么叫逻辑上？因为实际上它允许你一个 View 同时扮演两个角色</em></li>
</ol></li>
<li><code>ns child</code>会在收到<code>DOWN</code>事件时，找到自己祖上中最近的能与自己匹配的<code>ns parent</code>，与它进行绑定并关闭它的事件拦截机制</li>
<li>然后<code>ns child</code>会在接下来的<code>MOVE</code>事件中判定出用户触发了滑动手势，并把事件流拦截下来给自己消费</li>
<li>消费事件流时，对于每一次<code>MOVE</code>事件增加的滑动距离：

<ol>
<li><code>ns child</code>并不是直接自己消费，而是先把它交给<code>ns parent</code>，让<code>ns parent</code>可以在<code>ns child</code>之前消费滑动</li>
<li>如果<code>ns parent</code>没有消费或是没有消费完，<code>ns child</code>再自己消费剩下的滑动</li>
<li>如果<code>ns child</code>自己还是没有消费完这个滑动，会再把剩下的滑动交给<code>ns parent</code>消费</li>
<li>最后如果滑动还有剩余，<code>ns child</code>可以做最终的处理</li>
</ol></li>
<li>同时在<code>ns child</code>的<code>computeScroll()</code>方法中，<code>ns child</code>也会把自己因为用户<code>fling</code>操作引发的滑动，与上一条中用户滑动屏幕触发的滑动一样，使用「parent -&gt; child -&gt; parent -&gt; child」的顺序进行消费</li>
</ol>

<blockquote>
<p>注：</p>

<ol>
<li>以上过程参考当前最新的<code>androidx.core 1.1.0-alpha01</code>中的<code>NestedScrollView</code>和<code>androidx.recyclerView 1.1.0-alpha01</code>中的<code>RecyclerView</code>实现，与之前的版本细节略有不同，后文会详述其中差异</li>
<li>为了理解上的方便，有几处细节的描述做了简化：其实在<code>NestedScrollView</code>、<code>RecyclerView</code>这类经典实现中：

<ol>
<li>在 <code>ns child</code> 滚动时，只要用户手指一按下，<code>ns child</code> 就会拦截事件流，不用等到判断出滑动手势（具体可以关注源码中的 <code>mIsBeingDragged</code> 字段）

<ol>
<li>这个细节是合理的，会让用户体验更好</li>
<li>（后文将不会对这个细节再做说明，而是直接用简化的描述，实现时如果要提高用户体验，需要注意这个细节）</li>
</ol></li>
<li>按照 Android 的触摸事件分发规则，如果 <code>ns child</code> 内部没有要消费事件的 View，事件也将直接交给 <code>ns child</code> 的 <code>onTouchEvent()</code> 消费。这时在 <code>NestedScrollView</code> 等 <code>ns child</code> 的实现中，接下来<span id="bug_n_1"><strong>在 <code>onTouchEvent()</code> 里判断出用户是要滑动自己之前，就会把用户的滑动交给 <code>ns parent</code> 进行消费</strong></span><em>（<a href="#title_4_4">回到4.4</a>）</em>

<ol>
<li>这个设计我个人觉得不太合理，既然是传递滑动那就应该在判断出用户确实在滑动之后才开始传递，而不是这样直接传递，而且在后文的实践部分，你确实能看到这种设计带来的问题</li>
<li>（后文的描述中如果没有特别说明，也是默认忽略这个细节）</li>
</ol></li>
</ol></li>
<li>描述中省略了关于直接传递 fling 的部分，因为这块的设计存在问题，而且最新版本这部分机制的作用已经非常小了，后面这点会详细讲</li>
</ol>
</blockquote>

<!--
在判断出滑动手势之前，就可能拦截事件流。具体可以关注源码中的 `mIsBeingDragged` 字段，在 `ns child` 滚动时，只要用户一按下，`ns child` 就会拦截事件流，而且在拦截之后，也不用等到判断出滑动手势，`ns child`就会把滑动距离交给`ns parent`。这个细节是比较合理的，会让用户体验更好一点（后文将不会对这个细节再做说明，直接用简化的描述）
-->

<p>你会发现，这跟我们自己实现嵌套滑动的方式非常像，但它有这些地方做得更好（具体怎么实现的见后文）</p>

<ol>
<li><code>ns child</code>使用更灵活的方式找到和绑定自己的<code>ns parent</code>，而不是直接找自己的上一级结点</li>
<li><code>ns child</code>在<code>DOWN</code>事件时关闭<code>ns parent</code>的事件拦截机制单独用了一个 Flag 进行关闭，这就不会关闭<code>ns parent</code>对其他手势的拦截，也不会递归往上关闭祖上们的事件拦截机制。<code>ns child</code>直到在<code>MOVE</code>事件中确定自己要开始滑动后，才会调用<code>requestDisallowInterceptTouchEvent(true)</code>递归关闭祖上们全部的事件拦截</li>
<li>对每一次<code>MOVE</code>事件传递来的滑动，都使用「parent -&gt; child -&gt; parent -&gt; child」机制进行消费，让<code>ns child</code>在消费滑动时与<code>ns parent</code>配合更加细致、紧密和灵活</li>
<li>对于因为用户<code>fling</code>操作引发的滑动，与用户滑动屏幕触发的滑动使用同样的机制进行消费，实现了完美的惯性连续效果</li>
</ol>

<h3 id="toc_9">2.2. 使用</h3>

<p>到这一步，我们再来看看 Google 给 View 和 ViewGroup 加了哪些方法？又希望我们什么时候怎么去调用它们？</p>

<p>加入的需要你关心的方法一共有这些（只注明了关键返回值和参数，参考当前最新的版本 <code>androidx.core 1.1.0-alpha01</code>）：</p>

<!--可能跟你看到的听到的都不一样，但不要着急反对，听我慢慢解释-->

<pre><code class="language-kotlin">// 『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)      // 覆写
</code></pre>

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

<ol>
<li>在你实现一个<code>ns child</code>角色时，你需要：

<ol>
<li>在实例化的时候调用<code>setNestedScrollingEnabled(true)</code>，启用嵌套滑动机制</li>
<li>在<code>DOWN</code>事件时调用<code>startNestedScroll()</code>方法，它会「找到自己祖上中最近的与自己匹配的<code>ns parent</code>，进行绑定并关闭<code>ns parent</code>的事件拦截机制」</li>
<li>在判断出用户正在进行滑动后

<ol>
<li>先常规操作：关闭祖上们全部的事件拦截，同时拦截自己子 View 的事件</li>
<li>然后调用<code>dispatchNestedPreScroll()</code>方法，传入用户的滑动距离，这个方法会「触发<code>ns parent</code>对滑动的消费，并且把消费结果返回」</li>
<li>然后<code>ns child</code>可以开始自己消费剩下滑动</li>
<li><code>ns child</code>自己消费完后调用<code>dispatchNestedScroll()</code>方法，传入最后没消费完的滑动距离，这个方法会继续「触发<code>ns parent</code>对剩下滑动的消费，并且把消费结果返回」</li>
<li><code>ns child</code>拿到最后没有消费完的滑动，做最后的处理，比如显示 overscroll 效果，比如在 fling 的时候停止<code>scroller</code></li>
</ol></li>
<li>如果你希望惯性滑动也能传递给<code>ns parent</code>，那么在<code>View</code>的<code>computeScroll()</code>方法中，对于每个<code>scroller</code>计算到的滑动距离，与<code>MOVE</code>事件中处理滑动一样，按照这个顺序进行消费：「<code>dispatchNestedPreScroll()</code> -&gt; 自己 -&gt; <code>dispatchNestedScroll()</code> -&gt; 自己」</li>
<li>在<code>UP</code>、<code>CANCEL</code>事件中以及<code>computeScroll()</code>方法中惯性滑动结束时，调用<code>stopNestedScroll()</code>方法，这个方法会「打开<code>ns parent</code>的事件拦截机制，并取消与它的绑定」</li>
</ol></li>
<li>在你实现一个<code>ns parent</code>角色时，你需要：

<ol>
<li>重写方法<code>boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)</code>，通过传入的参数，决定自己对这类嵌套滑动感兴趣，在感兴趣的情况中返回<code>true</code>，<code>ns child</code>就是通过遍历所有<code>ns parent</code>的这个方法来找到与自己匹配的<code>ns parent</code></li>
<li>如果选择了某种情况下支持嵌套滑动，那么在拦截滑动事件前，调用<code>getNestedScrollAxes()</code>，它会返回你某个方向的拦截机制是否已经被<code>ns child</code>关闭了，如果被关闭，你就不应该拦截事件了</li>
<li>开启嵌套滑动后，你可以在<code>onNestedPreScroll</code>和<code>onNestedScroll</code>方法中耐心等待<code>ns child</code>的消息，没错，它就对应了你在<code>ns child</code>中调用的<code>dispatchNestedPreScroll</code>和<code>dispatchNestedScroll</code>方法，你可以在有必要的时候进行自己的滑动，并且把消耗掉的滑动距离通过参数中的数组返回</li>
</ol></li>
</ol>

<p>这么实现的例子可以看 <code>ScrollView</code>，只要打开它的<code>setNestedScrollingEnabled(true)</code>开关，你就能看到嵌套滑动的效果：<em>（实际上<code>ScrollView</code>实现的不是完美的嵌套滑动，原因见下一节）</em><br/>
<img src="media/15467469615604/2019-01-10_15-50-11.gif" alt="" style="width:200px;"/></p>

<!--
有人可能会问，为什么感觉写起来有点麻烦啊，比自己实现还要麻烦？就不能一句话导入，两句话使用吗？

1. 因为滑动过程本身就相对复杂，难以找到一个统一的切面插入`NestedScrolling`机制，它作为整体滑动机制的一部分，只能把自己打散然后穿插到整体滑动机制中合适的位置
2. 如果你要写得灵活和健壮，你的代码不会比它少，对于简单的需求，它也不会增加你太多的工作量
-->

<p><code>ns parent</code>还好，但<code>ns child</code>的实现还会有大量的细节（包括实践部分会提到的「<code>ns parent</code>偏移导致的 <code>event</code> 校正」等等），光是描述可能不够直接，为此我也为<code>ns child</code>准备了一份参考模板：<a href="https://github.com/RubiTree/NestedScrollingHistory/blob/master/app/src/main/java/com/rubitree/demo/sample/NestedScrollChildSample.java"><code>NestedScrollChildSample</code></a></p>

<blockquote>
<p>注意</p>

<ol>
<li>虽然模板在IDE里不会报错，但这不是可以运行的代码，这是剔除 <code>NestedScrollView</code> 中关于 <code>ns parent</code> 的部分，得到的可以认为是官方推荐的 <code>ns child</code> 实现</li>
<li>同时，为了让主线逻辑更加清晰，删去了多点触控相关的逻辑，实际开发如果需要，可以直接参考 <code>NestedScrollView</code> 中的写法，不会麻烦太多<em>（有空会写多点触控的透镜系列XD）</em></li>
<li>其中的关键部分是在触摸和滚动时怎么调用 <code>NestedScrollingChild</code> 接口的方法，也就是 <code>onInterceptTouchEvent()</code> 、 <code>onTouchEvent()</code> 、<code>computeScroll()</code> 中大约 200 行的代码</li>
</ol>
</blockquote>

<p>另外，以上都说的是单一角色时的使用情况，有时候你会需要一个 View 扮演两个角色，就需要再多做一些事情，比如对于<code>ns parent</code>，你要时刻注意你也是 <code>ns child</code>，在来生意的时候也照顾一下自己的<code>ns parent</code>，这些可以去看 <code>NestedScrollView</code> 的实现，不在这展开了。</p>

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<h2 id="toc_10">3. 历史的消防车滚滚向前</h2>

<p><span id="questions">但是有人就<strong>问</strong>了：</span><em>（<a href="#answers">回到答案</a>）</em></p>

<ol>
<li>我怎么看到别人讲，你必须实现<code>NestedScrollingParent</code>和<code>NestedScrollingChild</code>这两个接口，然后利用上<code>NestedScrollingParentHelper</code>和<code>NestedScrollingChildHelper</code>这两个帮助类，才能实现一个支持嵌套滑动的自定义 View 啊，而且大家都称赞这是一种很棒的设计呢，怎么到你这就变成了直接加在View和 ViewGroup 里的方法了，这么普通的 DISCO 嘛？而且题图里也看到有这几个接口的啊，你难道是标题党吗？<del>（赞一个居然还记得题图）</del></li>
<li>为什么不用实现接口也能实现嵌套滑动，又为什么几乎所有实现嵌套滑动的 View 又都实现了这两个接口呢？</li>
<li>为什么明明嵌套滑动机制在<code>NestedScrollingParent</code>和<code>NestedScrollingChild</code>这两个接口里放了那么多方法，你却只讲9个呢？</li>
<li>为什么接口里的 fling 系列方法你不讲？</li>
<li>为什么有<code>NestedScrollingChild</code>，有<code>NestedScrollingChild2</code>，工作不饱和的同学会发现最近 Google 还增加了<code>NestedScrollingChild3</code>，这都是在干哈？改了些什么啊？</li>
</ol>

<p>别着急，要解释这些问题，还得先来了解下<span id="history_detail">历史</span>，翻翻<a href="https://developer.android.com/studio/releases/platforms"><code>sdk</code></a>和<a href="https://developer.android.com/topic/libraries/support-library/rev-archive"><code>support library</code></a>家的老黄历：<br/>
（嫌弃太长也可以直接<a href="#history_summarize">前往观看小结</a>）<br/>
<del>（事情要从五年前说起...）</del></p>

<h3 id="toc_11">3.1. 第一个版本，2014年9月</h3>

<p>在 <code>Android 5.0 /  API  21 (2014.9)</code> 时， Google 第一次加入了 NestedScrolling 机制。</p>

<p>虽然在<a href="https://developer.android.com/about/versions/android-5.0">版本更新</a>里完全没有提到，但是在<a href="http://androidxref.com/5.0.0_r2/xref/frameworks/base/core/java/android/View/View.java"><code>View</code></a>和 <code>ViewGroup</code> 的源码里你已经能看到其中的嵌套滑动相关方法。<br/>
而且此时使用了这些方法实现了嵌套滑动效果的 View 其实已经有不少了，除了我们讲过的<code>ScrollView</code>，还有<code>AbsListView</code>、<code>ActionBarOverlayLayout</code>等，而这些也基本是当时所有跟滑动有关的 View 了。<br/>
所以，如上文嵌套<code>ScrollView</code>的例子所示，在<code>Android 5.0</code>时大家其实就能通过<code>setNestedScrollingEnabled(true)</code>开关启用 View 的嵌套滑动效果。</p>

<p>这是 NestedScrolling 机制的第一版实现。</p>

<h3 id="toc_12">3.2. 重构第一个版本，2015年4月</h3>

<p>因为第一个版本的 NestedScrolling 机制是加在 framework 层的 View 和 ViewGroup 中，所以能享受到嵌套滑动效果的只能是<code>Android 5.0</code>的系统，也就是当时最新的系统。<br/>
大家都知道，这样的功能不会太受开发者待见，所以在当时 NestedScrolling 机制基本没有怎么被使用。<em>（所以大家一说嵌套滑动就提后来才发布的<code>NestedScrollView</code>而不不知道<code>ScrollView</code>早就能嵌套滑动也是非常正常了）</em></p>

<p>Google 就觉得，这可不行啊，<del>嵌套滑不动的Bug不能老留着啊</del> 好东西得大家分享啊，于是一狠心，梳理了下功能，重构出来两个接口（<code>NestedScrollingChild</code>、<code>NestedScrollingParent</code>）两个 Helper （<code>NestedScrollingChildHelper</code>、<code>NestedScrollingParentHelper</code>）外加一个开箱即用的<code>NestedScrollView</code>，在 <a href="https://developer.android.com/topic/libraries/support-library/rev-archive#rev22-1-0"><code>Revision 22.1.0 (2015.4)</code></a> 到来之际，把它们一块加入了<code>v4 support library</code>豪华午餐。</p>

<p>这下大伙就开心了，奔走相告：嵌套滑动卡了吗，赶紧上<code>NestedScrollView</code>吧，<code>Android 1.6</code>也能用。<br/>
同时<code>NestedScrollingChild</code>和<code>NestedScrollingParent</code>也被大家知晓了，要自己整个嵌套滑动，那就实现这两接口吧。</p>

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

<p>NestedScrolling 机制终于走上台前，一时风头无两。</p>

<p>但注意，我比较了一下，这时的 NestedScrolling 机制相比之前放在 View 和  ViewGroup  中的第一个版本，其实完全没有改动，只是把 View 和 ViewGroup 里的方法分成两部分放到接口和 Helper 里了，<code>NestedScrollView</code>里跟嵌套滑动有关的部分也跟<code>ScrollView</code>里的没什么区别，所以此时的 NestedScrolling 机制本质还是第一个版本，只是形式发生了变化。</p>

<p>而 NestedScrolling 机制形式的变化带来了什么影响呢？</p>

<ol>
<li>把 NestedScrolling 机制从 View 和 ViewGroup 中剥离，把有关的 API 放在接口中，把相关实现放在 Helper 里，让每一个普通的低版本的 View 都能享受到嵌套滑动带来的乐趣，这就是它存在的意义啊（误</li>
<li>确实，因为这个机制其实不涉及核心的 framework 层的东西，所以让它脱离 API 版本存在，让低版本系统也能有嵌套滑动的体验，才是导致这个变化的主要原因也是它的主要优点。至于依赖倒置、组合大于继承应该都只是结果。而便于修复 Bug(×2) 什么的 Google 当时大概也没有想到。</li>
<li>同时，这么做肯定也不止有有优点，它也会有缺点，否则一开始就不会直接把机制加到 View 和 ViewGroup 里了，它的主要缺点有：

<ol>
<li><strong>使用麻烦</strong>。这是肯定的，本来放在 View 里拿来就用的方法，现在不仅要实现接口，还要自己去写接口的实现，虽然有 Helper 类进行辅助，但还是麻烦啊</li>
<li><strong>暴露了更多内部的不需要普通使用者关心的 API</strong>。这点我认为比上一点要重要一些，因为它会影响开发者对整个机制的上手速度。本来，如我前文介绍，你只需要知道有这9个方法就行，现在这一改，光 child 里就有9个，parent 里还有8个，接近 double 了。多的这些方法中有的是机制内部用来沟通的（比如<code>isNestedScrollingEnabled()</code>、<code>onNestedScrollAccepted()</code>），有的是设计别扭用得很少的（比如<code>dispatchNestedFling()</code>），有的是需要特别优化细节才需要的（比如<code>hasNestedScrollingParent()</code>），一开始开发者其实完全不用关心。</li>
</ol></li>
</ol>

<h4 id="toc_13">3.2.1. 第一个版本的Bug</h4>

<p><code>Android 1.6</code>也用上了嵌套滑动，老奶奶开心得合不拢嘴。但大家用着用着，新鲜感过去之后，也开始不满足了起来，于是就有了第一版 NestedScrolling 机制的著名Bug：<span id="bug1_1"><strong>「惯性不连续」</strong></span><em>（<a href="#history_summarize">回到小结</a>）</em></p>

<p>什么是惯性不连续？如下图<br/>
<img src="media/15467469615604/2019-01-09_13-51-04%20-1-.gif" alt="" style="width:200px;"/></p>

<p>简单说就是：你在滑动内部 View 时快速抬起手指，内部 View 会开始惯性滑动，当内部 View 惯性滑动到自己顶部时便停止了滑动，此时外部的可滑动 View 不会有任何反应，即使外部 View 可以滑动。<br/>
本来这个体验也没多大问题，但因为你手动滑动的时候，内部滑动到顶部时可以接着滑动外边的 View，这就形成了对比，有对比就有差距，有差距群众就不满意了，你不能在惯性滑动的时候也把里面的滑动传递到外面去吗？<br/>
所以这个问题也不能算是 Bug，只是体验没有做到那么好罢了。</p>

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

<p>这四个 API 长这样，看名字对应上 scroll 的4个 API 大概能知道是干什么的（但实际上有很大区别，见下文）：</p>

<ol>
<li>ns child：<code>dispatchNestedPreFling</code>、<code>dispatchNestedFling</code></li>
<li>ns parent：<code>onNestedPreFling</code>、<code>onNestedFling</code></li>
</ol>

<p>前面我在讲述的时候默认是让<code>ns child</code>直接消费用户快速抬起时产生的惯性滑动，这没有什么问题，因为我们还在<code>computeScroll</code>方法中把惯性引起的滑动也传递给了<code>ns parent</code>，让父子配合进行惯性滑动。<br/>
但实际上此时的<a href="http://androidxref.com/6.0.0_r1/xref/frameworks/support/v4/java/android/support/v4/widget/NestedScrollView.java#flingWithNestedDispatch"><code>NestedScrollView</code></a>是这么写的：</p>

<pre><code class="language-java">public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) &gt; mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll();
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY &gt; 0 || velocityY &gt; 0) &amp;&amp; (scrollY &lt; getScrollRange() || velocityY &lt; 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        if (canFling) fling(velocityY);
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() &gt; 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 的逻辑
    }
}
</code></pre>

<p>来读一下其中的逻辑</p>

<ol>
<li>首先看 API ，同滑动一样，设计者给惯性（速度）也设计了一套协同消费的机制，但是这套机制与滑动不太一样，或者说完全不同</li>
<li>在用户滑动<code>ns child</code>并快速抬起手指产生惯性的时候，看<code>flingWithNestedDispatch()</code>方法，<code>ns child</code>会先问<code>ns parent</code>是否消费此速度

<ol>
<li>如果消费，就把速度全部交出，自己不再消费</li>
<li>如果<code>ns parent</code>不消费，那么将再次把速度交给<code>ns parent</code>，并且告诉它自己是否有消费速度的条件<em>（根据系统类库一贯的写法，如果<code>ns child</code>消费这个速度，<code>ns parent</code>都不会对这个速度做处理）</em>，同时自己在有消费速度的条件时，对速度进行消费</li>
</ol></li>
<li>自己消费速度的方式是使用<code>mScroller</code>进行惯性滑动，但是在<code>computeScroll()</code>中并没有把滑动分发给 <code>ns parent</code> </li>
<li>最后只要抬起手指，就会调用<code>stopNestedScroll()</code>解除与<code>ns parent</code>的绑定，宣告这次协同合作到此结束</li>
</ol>

<p>那么总结一下:</p>

<ol>
<li>惯性的这套协同消费机制只能在惯性滑动前让<code>ns parent</code>有机会拦截处理惯性，它并不能在惯性滑动过程中让<code>ns child</code>和<code>ns parent</code>协同消费惯性引发的滑动，也就是实现不了前面人们期望的惯性连续效果，所以第一版的开发者想用直接传递惯性的方式实现惯性连续可能不是个好主意

<ol>
<li>另外，目前惯性的协同消费机制只会在<code>ns child</code>无法进行滑动的时候起到一定的作用（虽然完全可以用滑动的协同消费机制替代），而在之后的版本中，这个作用基本也没有被用到，它确实被滑动的协同消费机制替代了</li>
</ol></li>
<li>而实现惯性连续的方式其实非常简单，不需要增加新的机制，直接通过滑动的协同消费机制，在<code>ns child</code>进行惯性滑动时，把滑动传递出来，就可以了</li>
<li>所以第一版 NestedScrolling 机制本身是没有问题的，有问题的是那些系统控件使用这个机制的方式不对</li>
<li>所以修复这个Bug也很简单，只是比较繁琐：修改所有作为<code>ns child</code>角色使用了嵌套滑动机制的系统控件，惯性相关的 API 和处理逻辑都可以保留，只要在<code>computeScroll()</code>中把滑动用<code>dispatchNestedPreScroll()</code>和<code>dispatchNestedScroll()</code>方法分发给 <code>ns parent</code>，再更改一下解除与<code>ns parent</code>绑定的时机，放在 fling 结束之后</li>
<li>你自己的<code>ns child</code> View 可以直接改，但系统提供的<code>NestedScrollView</code>、<code>RecyclerView</code>等控件，你就只能提个 issue 等官方修复了，不过也可以拷贝一份出来自己改</li>
</ol>

<h3 id="toc_14">3.3. 第二个版本，2017年9月</h3>

<p><del>Google表示才不想搭理这些人，给你用就不错了哪来那么多事儿？我还要忙着搞AI呢</del> 直到两年多后的2017年9月，<code>Revision 26.1.0</code>才悄咪咪<em>（<a href="https://developer.android.com/topic/libraries/support-library/revisions#26-1-0">更新日志</a>里没有提，但是<a href="https://developer.android.com/reference/android/support/v4/View/NestedScrollingParent2">文档的添加记录</a>里能看到，后来发现作者自己倒是<a href="https://chris.banes.me/2017/06/09/carry-on-scrolling/">写了篇博客</a>说这事，说是<code>Revision 26.0.0-beta2</code>时加的，跟文档里写的不一致，不过这不重要）</em> 更新了一版<code>NestedScrollingChild2</code>和<code>NestedScrollingParent2</code>，并且处理了第一版中系统控件的Bug，这便是第二个版本的 NestedScrolling 机制了</p>

<p>来看看第二版是怎么处理第一版 Bug 的，大牛的救火思路果然比一般人要健壮。</p>

<p>首先看接口是怎么改的：</p>

<ol>
<li><code>ns child</code>在<code>computeScroll</code>中分发滑动给<code>ns parent</code>没有问题（这是关键），但是我要区分开是用户手指移动触发的滑动还是由惯性触发的滑动（这是锦上添花）</li>
<li>于是第二版中给所有<code>NestedScrollingChild</code>中滑动相关的<em>（确切地说是除了「fling相关、滑动开关」外的）</em> 5个方法、所有<code>NestedScrollingParent</code>中滑动相关的<em>（确切地说是除了「fling相关、获取滑动轴」外的）</em> 5个方法，都增加了一个参数<code>type</code>，<code>type</code>有两个取值代表上述的两种滑动类型：<code>TYPE_TOUCH</code>、<code>TYPE_NON_TOUCH</code></li>
<li>所以第二版的两个接口没有增删任何方法，只是给10个方法加了个<code>type</code>参数，并且对旧的接口做了个兼容，让它们的<code>type</code>是<code>TYPE_TOUCH</code></li>
</ol>

<p>改完了接口当然还要改代码了，Helper 类首先要改</p>

<ol>
<li><a href="http://androidxref.com/6.0.0_r1/xref/frameworks/support/v4/java/android/support/v4/View/NestedScrollingChildHelper.java">第一版的 <code>NestedScrollingChildHelper</code></a> 里边本来持有了一个<code>ns parent</code> 域 <code>mNestedScrollingParentTouch</code>，作为绑定关系，<a href="http://androidxref.com/8.0.0_r4/xref/frameworks/support/core-ui/java/android/support/v4/View/NestedScrollingChildHelper.java">第二版</a> 又再加了一个<code>ns parent</code> 域 <code>mNestedScrollingParentNonTouch</code>，为什么是两个而不是公用一个，大概是避免对两类滑动的生命周期有过于严格的要求，比如在 <code>NestedScrollView</code> 的实现里，就是先开启<code>TYPE_NON_TOUCH</code>类型的滑动，然后关闭了 <code>TYPE_TOUCH</code> 类型的滑动，如果公用一个 <code>ns parent</code> 域，就做不到这样了</li>
<li><code>NestedScrollingChildHelper</code> 里边主要就做了这一点额外的改动，其他的改动都是增加参数后的常规变换，<code>NestedScrollingParentHelper</code> 里就更没有特别的变化了</li>
</ol>

<p>前面在分析第一版 Bug 的时候说过「第一版 NestedScrolling 机制本身是没有问题的，有问题的是那些系统控件使用这个机制的方式不对」，所以这次改动最大的还是那些使用了嵌套滑动机制的系统控件了，我们就以 <code>NestedScrollView</code> 为例来具体看看系统是怎么修复 Bug、建议大家现在应该怎么创建 <code>ns child</code> 角色的。<br/>
相同的部分不说了，在调用相关方法的时候要传入 <code>type</code> 也不细说了，主要的变化基本出现在预期的位置：</p>

<pre><code class="language-java">public boolean onTouchEvent(MotionEvent ev) {
    ...
    case MotionEvent.ACTION_UP:
        if (mIsBeingDragged) {
            ...
    
            if ((Math.abs(initialVelocity) &gt; mMinimumVelocity)) {
                flingWithNestedDispatch(-initialVelocity);
            }
    
            stopNestedScroll(ViewCompat.TYPE_TOUCH);
        }
        break;
    ...
}
    
private void flingWithNestedDispatch(int velocityY) {
    final int scrollY = getScrollY();
    final boolean canFling = (scrollY &gt; 0 || velocityY &gt; 0) &amp;&amp; (scrollY &lt; getScrollRange() || velocityY &lt; 0);
    
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        fling(velocityY); // 华点
    }
}
    
public void fling(int velocityY) {
    if (getChildCount() &gt; 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);
    }
}
</code></pre>

<p><code>computeScroll()</code>方法的代码贴得比较多，因为它不仅是这次Bug修复的主要部分，它还是下一次Bug修复要改动的部分。<br/>
不过其实整个逻辑还是很简单的，符合预期，简单说明一下：</p>

<ol>
<li><code>UP</code>时候做的事情没有变，还是在这解除了与<code>ns parent</code>的绑定，但是注明了类型是<code>TYPE_TOUCH</code></li>
<li><code>flingWithNestedDispatch()</code>这个方法先不说</li>
<li>在<code>fling()</code>方法中，调用<code>startNestedScroll()</code>开启了新一轮绑定，不过这时的类型变成了<code>TYPE_NON_TOUCH</code></li>
<li>最多的改动是在<code>computeScroll()</code>方法中，但逻辑很清晰：对于每个<code>dy</code>，都会经过「parent -&gt; child -&gt; parent -&gt; child」这个消费流程，从而实现了惯性连续，解决了 Bug</li>
</ol>

<p>最后的效果是这样：<br/>
<img src="media/15467469615604/2019-01-09_13-58-03%20-1-.gif" alt="" style="width:200px;"/></p>

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

<h4 id="toc_15">3.3.1. 第二个版本的Bug</h4>

<p>看上去第二个版本改得很漂亮对吧，但这次改动其实又引入了两个问题，至少有一个算是Bug，另一个可以说只是交互不够好，不过这个交互不够好的问题引入的原因却非常令人迷惑。</p>

<p>先说第一个问题：<span id="bug2_1"><strong>「二倍速」</strong></span><em>（<a href="#history_summarize">回到小结</a>）</em><br/>
<img src="media/15467469615604/2019-01-09_14-01-43%20-1-.gif" alt="" style="width:200px;"/></p>

<ol>
<li>我只知道它正好出现在了<code>NestedScrollView</code>中，<code>RecyclerView</code>等类没有这个问题，我极度怀疑它的引入是因为手滑</li>
<li>它的现象是这样：当外部 View 不在顶部、内部 View 在顶部时，往下滑动内部 View 然后快速抬起（制造 fling ）

<ol>
<li>预期效果应该是：外部 View 往下进行惯性滑动</li>
<li>实际上也大概是这样，但有一点点区别：外部 View 往下滑动的速度会比你预想中要快，大概是两倍的速度（反方向也是一样），如下图</li>
</ol></li>
<li>为什么会这样呢？

<ol>
<li>你如果把第二版嵌套滑动机制更新的<code>NestedScrollView</code>跟之前的对比，你会很容易发现<code>flingWithNestedDispatch()</code>中（在我贴出来的代码里），<code>fling(velocityY)</code>前的<code>if (canFling)</code>离奇消失了</li>
<li>但消失不代表是手滑，可能是逻辑使然，于是梳理了一下逻辑，这个 if 判断在新的机制中需要去掉吗？额，并不需要。没有了 if 会让外部 View 同时进行两个 fling，实际体验也确实是这样</li>
</ol></li>
<li>所以解决这个问题很简单，直接把 if 判断补上就好了</li>
<li>不过这个问题在体验上不算明显，不过也不难发现，只是用户可能不知道这是个 Bug 还是 Feature（233</li>
</ol>

<p></br></p>

<p>然后是第二个问题：<span id="bug2_2"><strong>「空气马达」</strong></span><em>（<a href="#history_summarize">回到小结</a>）</em><br/>
<img src="media/15467469615604/2019-01-09_14-05-22%20-1-.gif" alt="" style="width:200px;"/></p>

<ol>
<li>这个问题肯定算 Bug 了，所有的嵌套滑动控件都存在，而且体验非常明显</li>
<li>这个问题就比较硬核了，真的是 NestedScrolling 机制的问题，确切地说应该叫缺陷，在第一版中就存在，只是第一版中系统控件的不当的机制使用方式正好不会触发这个问题，但是在第二版后，各个控件改用了新的使用方式，这个问题终于暴露出来了</li>
<li>它的现象是这样：当外部 View 在顶部、内部 View 也在顶部时，往下滑动内部 View 然后快速抬起（制造 fling ），<em>（目前什么都不会发生，因为都滑到顶了，关键是下一步）</em> 你马上滑<strong>外部 View</strong>

<ol>
<li>预期应该是：外部 View 往上滚动</li>
<li>但实际上你会发现：你滑不动它，或是滑上去一点，马上又下来了，像是有一台无形的马达在跟你的手指较劲（反方向也是一样），如上图</li>
</ol></li>
<li>为什么会这样呢？

<ol>
<li>其实我开始也不得要领，只好打日志去看到底谁是那个马达，调试了好一会<em>（当时还闹了个笑话有空再写）</em>才发现原来马达就是内部 View</li>
<li>原因解释起来也是非常简单的：

<ol>
<li>先回头看方法<code>flingWithNestedDispatch()</code>中的这段代码：其中的<code>dispatchNestedPreFling()</code>大部分时候会返回<code>false</code>，于是几乎所有的情况下，内部 View 都会通过<code>fling()</code>方法启动自己<code>mScroller</code>这个小马达</li>
<li>然后在小马达启动后，到<code>computeScroll()</code>方法中，你会看到，（如果你不直接触摸内部View） <strong>除非等到马达自己停止，否则没有外力能让它停下</strong>，于是它会一直向外输出<code>dispatchNestedPreScroll()</code>和<code>dispatchNestedScroll()</code></li>
<li>所以在上面的现象中，即使内外的 View 都在顶部，都无法滑动，内部 View 的小马达还在突突突地工作，只要你把外部 View 滑到不在顶部的位置，它就又会把它给滑下来</li>
<li>所以其实不需要前面说的「当外部View在顶部、内部View也在顶部时」这种场景（这只是最好复现的场景），当以任何方式开启了内部 View 的小马达后，你又不通过直接触摸内部 View 把它关闭时，都能看到这个问题</li>
</ol></li>
</ol></li>
<li>那怎么办？这个问题的症结在哪儿？

<ol>
<li>首先内部 View 的小马达是不能废弃的，没有它，怎么突突突地驱动外部 View 呢？</li>
<li>但也不能任它突突突转个不停，除了用户直接触摸内部 View 让它停止，它还需要有一个停止开关，至少让用户触摸外部 View 的时候也能关闭它，更合理的实现还应该让驱动过程能够反馈，当出现情况无法驱动（比如内外都滑到顶部）时，停下马达</li>
</ol></li>
<li>所以现在需要给驱动过程增加反馈

<ol>
<li><a href="#ns_roles">前文讲过</a>，这个机制中<code>ns child</code>是主动的一方，<code>ns parent</code>完全是被动的，<code>ns parent</code>没法主动通知<code>ns child</code>：啊我被摁住了，啊我撞墙了</li>
<li>但<code>ns parent</code>并不是没办法告知<code>ns child</code>信息，通过方法的返回值和引用类型的参数，<code>ns child</code>仍然可以从<code>ns parent</code>中获取信息</li>
<li>所以只要给 NestedScrolling 机制加一组方法，让<code>ns child</code>询问<code>ns parent</code>是否能够滑动，问题应该就解决了：如果<code>ns parent</code>滑不动了，<code>ns child</code>自己也滑不动，那就赶紧关闭马达吧，<del>节约能源人人有责</del></li>
</ol></li>
<li>咱想得确实美，但咱又吃不上G家的饭， NestedScrolling 机制不是你写的，你怎么给整个机制加个方法？好吧，那只能看看这个 NestedScrolling 机制有什么后门能利用了

<ol>
<li>一尝试就发现可能有戏，询问<code>ns parent</code>是否能够滑动不是有现成的方法吗？</li>
<li><code>dispatchNestedPreScroll()</code>会先让<code>ns parent</code>在<code>ns child</code>之前进行滑动，而且滑动的距离被记录在它的数组参数<code>consumed</code>中，拿到数组中的值<code>ns child</code>就能知道<code>ns parent</code>是否在这时滑动了</li>
<li><code>dispatchNestedScroll()</code>会让<code>ns parent</code>在<code>ns child</code>之后进行滑动，它有没有数组参数记录滑动距离，它只有一个返回值记录是否消费了滑动...不对，这个返回值不是记录是否消费滑动用的，它表示的是<code>ns parent</code>是否能顺利联系上，如果能，就返回<code>true</code>，并不关心它是否消费了滑动。在<code>NestedScrollingChildHelper</code>中你也能看到这个逻辑的清晰实现，同时你也会看到在<code>NestedScrollingParent2</code>中它对应的方法是<code>void onNestedScroll()</code>，没有返回值<em>（考虑过能不能通过<code>dispatchNestedScroll()</code>中<code>int[] offsetInWindow</code>没被使用的数组位置来传递信息，结果也因为 parent 中对应的方法不带这个参数而告终；而且<code>ns parent</code>也无法主动解除自己与<code>ns child</code>的绑定，这条路也不通）</em>。总之，<code>dispatchNestedScroll()</code>无法让<code>ns child</code>得知<code>ns parent</code>对事件的消费情况，此路不通</li>
<li>（其实之后通过把<code>dispatchNestedScroll()</code>的消费结果直接放在<code>ns child</code>的 View 中，用这个后门解决了Bug，但这种方式使用的局限比较大，而且下面要介绍的最新的第三版已经修复了这个问题，我就不多写了）</li>
</ol></li>
</ol>

<!--，有需要的可以看这里的代码@@@-->

<h3 id="toc_16">3.4. 第三个版本，2018年11月</h3>

<p>第二版的 Bug 虽然比第一版的严重，但好像没有太多人知道，可能这种使用场景还是没有那么多。<br/>
不过时隔一年多，Google 终于是意识到了这个问题，在最近也就是2018年11月5日<code>androidx.core 1.1.0-alpha01</code>的<a href="https://developer.android.com/jetpack/androidx/androidx-rn#2018-nov-core">更新</a>中，给出了最新的修复——<code>NestedScrollingChild3</code>和<code>NestedScrollingParent3</code>，以及一系列系统组件也陆续进行了更新。</p>

<p>这就是第三个版本的 NestedScrolling 机制了，这个版本确实对上面两个 Bug 进行了处理，但可惜的是，第二个 Bug 并没有修理干净 <del>（为 Google 大佬献上一首つづく，期待第四版）</del><br/>
<em>（在本文快要完成的时候正好看到新一任消防员在18年12月3日发了条 <a href="https://twitter.com/sockeqwe/status/1069844582769328128?lang=en">twitter</a> 说已经发布了第三版，结果评论区大家已经在欢乐地期待 <code>NestedScrollingChild42</code> <code>NestedScrollingChildX</code> <code>NestedScrollingParentXSMax</code> <code>NestedScrollingParentFinalFinalFinal</code> <code>NestedScrollingParent2019</code> 了 ）</em></p>

<p>继续来看看在这个版本中，大佬是怎么救火的</p>

<p>照例先看接口，一看接口的改动你可能就笑了，真的是哪里不通改哪里</p>

<ol>
<li>在接口<code>NestedScrollingChild3</code>中，没有增加方法，只是给<code>dispatchNestedScroll</code>方法增加了一个参数<code>int[] consumed</code>，并且把它的<code>boolean</code>返回值改成了<code>void</code>，有了能获取更详细信息的途径，自然就不需要这个<code>boolean</code>了</li>
<li>接口<code>NestedScrollingParent3</code>同样只是改了一个方法，给<code>onNestedScroll</code>增加了<code>int[] consumed</code>参数（它返回值就是 <code>void</code>，没变）</li>
</ol>

<p>下面是<code>NestedScrollingChild3</code>中的对比：</p>

<pre><code class="language-java">// 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 // 这个
);
</code></pre>

<p>再看下 Helper ，<code>NestedScrollingChildHelper</code>除了适配新的接口基本没有改动，<code>NestedScrollingParentHelper</code>也只是增强了一点逻辑的严谨性（大概是被review了233）</p>

<p>最后看用法，还是通过我们的老朋友<code>NestedScrollView</code>来看，改动部分跟预期基本一致：</p>

<pre><code class="language-java">@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);
}
</code></pre>

<p>修改最多的还是<code>computeScroll()</code>，不过其他地方也有些变化，简单说明一下：</p>

<ol>
<li>因为<code>onNestedScroll()</code>增加了记录距离消耗的参数，所以<code>ns parent</code>就需要把这个数据记录上并且继续传递给自己的<code>ns parent</code></li>
<li><code>flingWithNestedDispatch()</code>是之前有蜜汁 Bug 的方法，本来我的预期是恢复第一版的写法，也就是把<code>fling(velocityY)</code>前的<code>if (canFling)</code>加回来，结果这下倒好，连<code>canFling</code>也不判断了，<code>dispatchNestedFling(0, velocityY, true)</code>直接传<code>true</code>，<code>fling(velocityY)</code>始终调用。这意味着什么呢？需要结合大部分View的写法来看

<ol>
<li>搜索<code>API  28</code>的代码你就会看到：

<ol>
<li>对于<code>onNestedPreFling()</code>方法，除了<code>ResolverDrawerLayout</code>会在某些情况下消费fling并返回<code>true</code>，以及<code>CoordinatorLayout</code>会象征性地问一遍自己孩子们的<code>Behavior</code>，其它的写法都是直接返回<code>false</code></li>
<li>对于<code>onNestedFling(boolean consumed)</code>方法，所有的写法都是，只要<code>consumed</code>为<code>true</code>，就什么都不会做，这种做法也非常自然</li>
</ol></li>
<li>所以当前的现状是：绝大部分情况下，内部 View 的 fling 小马达都会启动，外部 View 都不会消费内部 View 产生的 fling。这就代表着：惯性的协作机制完全被滑动的协作机制取代了。这也是我不推荐给初学者介绍这组没什么用的接口的原因</li>
<li>但当然，即使名存实亡，但如果你真的有特殊需求需要使用到 fling 的传递机制，你也是可以用的</li>
</ol></li>
<li>最后来看<code>computeScroll()</code>，它基本把我们在讨论怎么修复第二版中 Bug 时的思路实现了：因为能从<code>dispatchNestedPreScroll()</code>和<code>dispatchNestedScroll()</code>得知<code>ns parent</code>消耗了多少这一次分发出去的滑动距离，同时也有自己消耗了多少，两者一合计，如果还有没消耗的滑动距离，那肯定无论内外都滑到头了，于是就该果断就把小马达关停</li>
</ol>

<p>现在的效果是这样的，能看到第二版中的Bug确实解决了<br/>
<img src="media/15467469615604/2019-01-09_14-13-48%20-1-.gif" alt="" style="width:200px;"/></p>

<h4 id="toc_17">3.4.1. 第三个版本的Bug</h4>

<p>那么为什么我还说第二个 Bug 没有解决彻底呢？</p>

<ol>
<li>对比代码容易看到，第三版中<code>DOWN</code>事件的处理相对第二版没有变化，它没有加入触摸外部 View 后关闭内部 View 马达的机制，更确切地说是没有加入「触摸外部 View 后阻止对内部 View 传递过来的滑动进行消费的机制」</li>
<li>所以只有外部 View 滑动到尽头的时候才能关闭马达，外部 View 没法给内部 View 反馈自己被摁住了</li>
</ol>

<p>虽然现象与「空气马达」类似，但还是按照惯例给它也起个好听的新名字，就叫：...<span id="bug3_1"><strong>「摁不住」</strong></span>吧<em>（<a href="#history_summarize">回到小结</a>）</em></p>

<p>实际体验跟分析结果一样这样，当通过滑动内部 View 触发外部 View 滑动时，你无法通过触摸外部 View 把它停下来，外部 View 比较长的时候容易复现，如下图（换了一个方向）<br/>
<img src="media/15467469615604/2019-01-09_14-17-17%20-1-.gif" alt="" style="width:200px;"/></p>

<p>不过这个问题只有可以响应触摸的<code>ns parent</code>需要考虑，可以响应触摸的<code>ns parent</code>主要就是<code>NestedScrollView</code>了，所以这个问题主要还是<code>NestedScrollView</code>的问题。而且它也跟机制无关，只是<code>NestedScrollView</code>的用法不对，所以前面说的会有第四版 NestedScrolling 机制可能性也不大，大概只会给<code>NestedScrollView</code>上个普通的更新吧（顺手给 Google 大佬递了瓶 <a href="https://issuetracker.google.com/issues/122804075">可乐</a>）</p>

<p>而这个问题自己改也非常好改，只需要在<code>DOWN</code>事件后能给<code>ns child</code>反馈自己被摁住了就行，可以用反射，或是直接把<code>NestedScrollView</code>挪出来改，关键代码如下</p>

<pre><code class="language-java">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);
}
</code></pre>

<p>我把用反射改好的放在<a href="https://github.com/RubiTree/NestedScrollingHistory/blob/master/library/src/main/java/com/rubitree/nestedscrolling4/NestedScrollView4.java">这里</a>了，你也可以<a href="https://github.com/RubiTree/NestedScrollingHistory">直接使用</a><br/>
改完之后效果如下：<br/>
<img src="media/15467469615604/2019-01-10_15-36-13-1.gif" alt="" style="width:200px;"/></p>

<h3 id="toc_18">3.5. 小结</h3>

<!--所以那些上来就介绍这17个方法的人，真的是劝退界老前辈了吧-->

<!--fling的传递没有讲，看完下面改动的细节你就懂了（很多博文这里讲得和笼统，跟scroll一样，如果你真这么想，你就错失了一个大瓜，隐约能从这个瓜里看到 Google 工程师救火时的英勇身姿）-->

<p>历史终于讲完了，<span id="history_summarize"><strong>小结一下</strong></span><em>（<a href="#history_detail">回去看详细历史</a>）</em></p>

<ol>
<li>2014年9月，Google 在<code>Android 5.0( API  21)</code>中的 View 和 ViewGroup 中加入了第一个版本的 NestedScrolling 机制，此时能够通过启用嵌套滑动，让嵌套的<code>ScrollView</code>不出现交互问题，但这个机制只有 API 21 以上才能使用</li>
<li>2015年4月，Google 重构了第一个版本的 NestedScrolling 机制，逻辑没有变化，但是把它从 View 和 ViewGroup 中剥离，得到了两个接口（<code>NestedScrollingChild</code>、<code>NestedScrollingParent</code>）和两个 Helper （<code>NestedScrollingChildHelper</code>、<code>NestedScrollingParentHelper</code>），并且用这套新的机制重写了一个默认启用嵌套滑动的<code>NestedScrollView</code>，并把它们都放入了<code>Revision 22.1.0</code>的<code>v4 support library</code>，让低版本的系统也能使用嵌套滑动机制，不过此时的第一版机制有<a href="#bug1_1">「惯性不连续」</a>的 Bug</li>
<li>2017年9月，Google 在<code>Revision 26.1.0</code>的<code>v4 support library</code>中发布了第二个版本的 NestedScrolling 机制，增加了接口<code>NestedScrollingChild2</code>、<code>NestedScrollingParent2</code>，主要是给原本滑动相关的方法增加了一个参数<code>type</code>，表示了两种滑动类型<code>TYPE_TOUCH</code>、<code>TYPE_NON_TOUCH</code>。并且使用新的机制重写了嵌套滑动相关的控件。这次更新解决了第一个版本中「惯性不连续」的Bug，但也引入了新的Bug：<a href="#bug2_1">「二倍速」</a>（仅<code>NestedScrollView</code>）和<a href="#bug2_2">「空气马达」</a></li>
<li>2018年11月，Google 给已经并入<code>AndroidX</code> 家族的 NestedScrolling 机制更新了第三个版本，具体版本是<code>androidx.core 1.1.0-alpha01</code>，增加了接口<code>NestedScrollingChild3</code>、<code>NestedScrollingParent3</code>，改动只是给原来的<code>dispatchNestedScroll()</code>和<code>onNestedScroll()</code>增加了<code>int[] consumed</code>参数。并且后续把嵌套滑动相关的控件用新机制进行了重写。这次更新解决了第二个版本中 <code>NestedScrollView</code>的「二倍速」Bug，同时期望解决「空气马达」Bug，但是没有解决彻底，还遗留了<a href="#bug3_1">「摁不住」</a>Bug</li>
</ol>

<p>所以<a href="#questions">前面的问题</a>大家应该都有了<span id="answers"><strong>答案</strong></span>：</p>

<ol>
<li>使用接口和 Helper 是为了兼容低版本和容易升级，并不是 NestedScrolling 机制用起来最方便的样子。所以为了便于理解，我就直接说调用 View 和 ViewGroup 的方法，但真正用的时候你最好还是在 Helper 的帮助下实现它最新的接口，然后再调用你实现的这些方法，因为 View 和 ViewGroup 的方法对 API 的版本要求高，自己的版本又很低。这点使用上的变化比较简单，因为方法名跟 View 和 ViewGroup 中的都一样，Helper 的使用也很直接，就不举例子了。</li>
<li>常用的方法也就是这9个了，剩下的8个不用急着去了解，其中 fling 相关方法有点凉凉的味道。然后第二版机制和第三版机制并没有增加新的方法，机制的总体设计没有大的变化。</li>
<li>第二版和第三版都是在修 Bug  <del>，恩，还没修完。</del></li>
</ol>

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<h2 id="toc_19">4. 实践</h2>

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

<h3 id="toc_20">4.1. 选题：悬停布局</h3>

<p>这个例子是「悬停布局」<br/>
你叫它粘性布局、悬浮布局、折叠布局都行，总之它理想的效果应该是这样：<br/>
<img src="media/15467469615604/2019-01-03_00-03-20%20-1-.gif" alt="" style="width:300px;"/></p>

<p>用文字描述是这样：</p>

<ol>
<li>页面内容分为 Header、悬停区（一般会是 TabLayout）和内容区，其中内容区可以左右滑动，有多个 Tab 页，而且每个 Tab 页是允许上下滑动的</li>
<li>用户向上滑动时，先折叠 Header，当 Header 全部折叠收起后，悬停区悬停不动，内容区向上滑动</li>
<li>用户向下滑动时，先把内容区向下滑动，然后展开 Header，悬停区顺势下移</li>
<li>其中内容区的滑动和 Header 的收起展开在用户连续滑动时应该表现为连续的，甚至在用户滑动中快速抬起时，滑动的惯性也需要在两个动作间保持连续</li>
</ol>

<p>在当前这个时间点（2019.1.13），这个例子还有不少实际意义，因为它虽然是比较常见的一个交互效果，但现在市场上的主流APP，居然是这样的...（饿了么v8.9.3）<br/>
<img src="media/15467469615604/2019-01-02_03-26-12%20-2-.gif" alt="" style="width:300px;"/><br/>
这样的...（知乎v5.32.2）<br/>
<img src="media/15467469615604/2019-01-02_03-48-02%20-1-.gif" alt="" style="width:300px;"/><br/>
这样的...（腾讯课堂v3.24.0.5）<br/>
<img src="media/15467469615604/2019-01-02_04-13-04%20-1-.gif" alt="" style="width:300px;"/><br/>
这样的...（哔哩哔哩v5.36.0）<br/>
<img src="media/15467469615604/2019-01-13_18-49-57.gif" alt="" style="width:300px;"/></p>

<p><img src="media/15467469615604/15463731835178.jpg" alt=""/></p>

<p>先不管它们是不是用 Native 实现的，只看实现的效果</p>

<ol>
<li>其中哔哩哔哩的视频详情页和美团（没有贴图）算是做得最好的，滑动连续惯性也连续，但也存在一个小瑕疵：在 Header 部分上下滑动时你可以同时进行左右滑动，容易误操作</li>
<li>而腾讯课堂的问题是最普遍的：惯性不连续</li>
<li>最奇葩是饿了么的店铺首页和知乎的 Live 详情页，都是创收的页面啊，居然能自带鬼畜，好吧，也是心大</li>
</ol>

<p>其他还有一些千奇百怪的 Bug 就不举例了。<br/>
所以，就让我们来看看，这个功能实现起来是不是真有那么难。</p>

<h3 id="toc_21">4.2. 需求分析</h3>

<p>如果内容区只有一个 Tab 页，一种简单直接的实现思路是：页面整个就是一个滑动控件，悬停区域会在滑动过程中不断调整自己的位置，实现悬停的效果。<br/>
它的实现非常简单，效果也完全符合要求，不举例了，可以自己试试。</p>

<p>但这里的需求是有多个 Tab 页，它用一整个滑动控件的思路是无法实现的，需要用多个滑动控件配合实现</p>

<ol>
<li>先看看有哪些滑动控件：每个 Tab 页内肯定是独立的滑动控件，要实现 Header 的展开收起，可以把整个容器作为一个滑动控件</li>
<li>这就变成了一个外部滑动控件和一组内部滑动控件进行配合，看上去有点复杂，但实际上在一次用户滑动过程中，只有一个外部滑动控件和一个内部滑动控件进行配合</li>
<li>配合过程是这样的（可以回头看下前面的理想效果动态图）：

<ol>
<li>用户上滑，外部滑动控件先消费事件进行上滑，直到滑动到 Header 的底部，外部滑动控件滑动结束，把滑动事件交给内部滑动控件，内部滑动控件继续滑动</li>
<li>用户下滑，内部滑动控件先消费事件进行下滑，直到滑动到内部控件的顶部，内部滑动控件滑动结束，把滑动事件交给外部滑动控件，外部滑动控件继续滑动</li>
<li>当用户滑动过程中快速抬起进行惯性滑动的时候，也需要遵循上面的配合规律</li>
</ol></li>
</ol>

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

<p>但具体到这个需求中</p>

<ol>
<li>首先，两个滑动控件配合消费事件的期望效果是，与内容区只有一个 Tab 页一样，让用户感知上认为自己在滑动一整个控件，只是其中某个部分会悬停，它并没有违背用户的直觉。所以，经过精心设计，多个View 消费同一个事件流也是可以符合用户直觉的。在这个领域表现最突出的就是<code>CoordinatorLayout</code>了，它就是用来帮助开发者去实现他们精心设计的多个 View 消费同一个事件流的效果的</li>
<li>然后，由于滑动反馈的简单性，让多个滑动控件的滑动进行配合也是能够做到的。你可以自己实现，也可以借助我们已经熟悉的<code>NestedScrolling</code>机制实现。另外<code>CoordinatorLayout</code>让多个滑动控件配合对同一个事件流进行消费也是利用<code>NestedScrolling</code>机制</li>
</ol>

<p>OK，既然需求提得没问题，而且我们也能实现，那下面就来看看具体要怎么实现。</p>

<p>可能有同学马上就举手了：我知道我知道，用<code>CoordinatorLayout</code>！<br/>
对，当前这个效果最常见的实现方式就是使用基于<code>CoordinatorLayout</code>的<code>AppBarLayout</code>全家桶，这是它的自带效果，通过简单配置就能实现，而且还附送更多其他特效，非常酷炫，前面看到的效果比较好的哔哩哔哩视频详情页就是用它实现的。<br/>
而<code>AppBarLayout</code>实现这个功能的方式其实是也使用了<code>CoordinatorLayout</code>提供的<code>NestedScrolling</code>机制（虽然实现的具体方法跟上面的分析有些区别，但并不重要，感兴趣的同学可以看<code>AppBarLayout</code>的<code>Behavior</code>），如果你嫌弃<code>AppBarLayout</code>全家桶太重了，只想单独实现悬停功能，如前文所述，你也可以直接使用<code>NestedScrolling</code>机制去实现。</p>

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

<h3 id="toc_22">4.3. 需求实现</h3>

<p>用<code>NestedScrolling</code>机制一想，你会发现实现起来非常简单，上面的分析过程在机制中直接就有对应的接口，我们只要实现一个符合要求的 <code>ns parent</code> 就好了，<code>NestedScrolling</code>机制会自动管理 <code>ns parent</code> 与 <code>ns child</code> 的绑定和 scroll 的传递，即使 <code>ns child</code> 与 <code>ns parent</code> 相隔好几层 View。</p>

<p>我把要实现的 <code>ns parent</code> 叫做 <code>SuspendedLayout</code> ，其中的关键代码如下，它剩下的代码以及布局和页面代码就不写出来了，可以在<a href="https://github.com/RubiTree/NestedScrollingHistory/blob/master/app/src/main/java/com/rubitree/demo/suspendedlayout/SuspendedLayoutActivity.kt">这里</a>查看（简单把第一个 child view 作为 Header，第二个 child view 会自然悬停）。</p>

<pre><code class="language-kotlin">override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
    if (dyUnconsumed &lt; 0) scrollDown(dyUnconsumed, consumed)
}

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy &gt; 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)
}
</code></pre>

<p>这么快就实现了，效果非常完美，与哔哩哔哩几乎一样：<br/>
<img src="media/15467469615604/2019-01-14_00-25-34.gif" alt="" style="width:300px;"/></p>

<h3 id="toc_23">4.4. 优化误操作问题</h3>

<p>但效果一样好也一样坏，哔哩哔哩的那个容易误操作的问题这里也有。<br/>
先看看为什么会出现这样的问题？</p>

<ol>
<li>从问题表现上很容易找到线索，肯定是在上滑过程中被 <code>ViewPager</code> 拦截了事件，也就是 <code>ns child</code> 没有及时「申请外部不拦截事件流」，于是到 <code>NestScrollView</code> 和 <code>RecyclerView</code> 中查看，问题其实就出在前面描述的<a href="#bug_n_1"><code>ns child</code> 在 <code>onTouchEvent()</code> 中的逻辑</a>上<span id="title_4_4"> </span></li>
<li>因为 <code>ns child</code> 会在判断出用户在滑动后「申请外部不拦截事件流」，但 <code>onTouchEvent()</code> 中又在判断出用户在滑动前就把滑动用 <code>dispatchNestedPreScroll()</code> 方法传递给了 <code>ns parent</code>，于是你就会看到，明明已经识别出我在上下滑动<code>ns child</code>了，而且已经滑了一段距离，居然会忽然切换成滑动 <code>ViewPager</code></li>
</ol>

<p>所以这个问题要怎么修复呢？</p>

<ol>
<li>直接修改源码肯定是解决办法

<ol>
<li>我尝试了把<code>NestScrollView</code>代码拷贝出来，并把其中的 <code>dispatchNestedPreScroll()</code> 方法放在判断出滑动之后进行调用，确实解决了问题</li>
</ol></li>
<li>但能不能不去拷贝源码呢？

<ol>
<li>也是可以的，只要能及时调用<code>parent.requestDisallowInterceptTouchEvent(true)</code>即可，<a href="https://github.com/RubiTree/NestedScrollingHistory/blob/master/library/src/main/java/com/rubitree/nestedscrolling4/rightrequest/RightRequestNestedScrollView.java">完整代码见此</a>，其中关键代码如下：</li>
</ol></li>
</ol>

<pre><code class="language-java">private int downScreenOffset = 0;
private int[] offsetInWindow = new int[2];

@SuppressLint(&quot;ClickableViewAccessibility&quot;)
@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(&quot;mActivePointerId&quot;));
        if (activePointerIndex != -1) {
            final int y = (int) ev.getY(activePointerIndex);
            int mLastMotionY = getInt(&quot;mLastMotionY&quot;);
            int deltaY = mLastMotionY - y - (getOffsetY() - downScreenOffset);

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

    return super.onTouchEvent(ev);
}

private int getOffsetY() {
    getLocationInWindow(offsetInWindow);
    return offsetInWindow[1];
}
</code></pre>

<p>这里有个细节值得一提：在计算<code>deltaY</code>时不只是用<code>mLastMotionY - y</code>，还减去了<code>(getOffsetY() - downScreenOffset)</code>，这里的<code>offsetInWindow</code>其实也出现在 NestedScrolling 机制里的<code>dispatchNestedScroll()</code>等接口中</p>

<ol>
<li><code>offsetInWindow</code>的作用非常关键，因为当 <code>ns child</code> 驱动 <code>ns parent</code> 滑动时，<code>ns child</code> 其实也在移动，此时<code>ns child</code>中获取到的手指触发的<code>motion event</code>中 <code>x</code>和<code>y</code>值是相对<code>ns child</code>的，所以此时如果直接使用<code>y</code>值，你会发现<code>y</code>值几乎没有变化，这样算到的<code>deltaY</code>也会没有变化，所以需要再获取<code>ns child</code>相对窗口的偏移，把它算入<code>deltaY</code>，才能得到你真正需要的<code>deltaY</code></li>
<li><code>ViewPager</code>为什么会在竖直滑动那么远之后还能对横滑进行拦截，也是这个原因，它获取到的<code>deltaY</code>其实很小</li>
</ol>

<p>改完之后的效果如下，能看到解决了问题：<br/>
<img src="media/15467469615604/2019-01-14_00-22-53.gif" alt="" style="width:300px;"/></p>

<p><code>RecyclerView</code>等其他的<code>ns child</code>如果需要的话，也可以做类似的改动（不过这里的反射代码对性能有所影响，建议实现上做一些优化）</p>

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<h2 id="toc_24">5. 总结</h2>

<p>如果你没有跳过地看到这里，关于 NestedScrolling 机制，我相信现在无论是原理、还是使用、甚至<del>八卦</del>历史，你都了解得一清二楚了，否则我只能怀疑<del>你的</del>我的语文<del>老师</del>表达水平了。</p>

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

<p>最后关于使用多说两句：</p>

<ol>
<li>如果你需要目前最好的嵌套滑动体验，不管是直接用系统 View 还是自定义 View ，直接用最新的 AndroidX 吧，并且自定义的时候注意使用3系列</li>
<li>如果你的项目暂时不方便切换 AndroidX，那么就升级到最新的 v4 吧，注意自定义的时候用2系列</li>
<li>如果你的项目追求极致体验，而且正好用到了嵌套的<code>NestedScrollView</code>，认为第三版的 Bug 也会影响到你宝贵而敏感的用户，那不如试试 implementation <a href="https://github.com/RubiTree/NestedScrollingHistory">我的项目</a> :D</li>
</ol>

<p>最后的最后，G 家的消防员都有顾不过来的时候，更何况是本菜鸡，本文内容肯定会有疏漏和不当之处，欢迎大家提 issue 啦~</p>

<!--（你也可以使用我基于2的机制魔改的`NestedScrollView`）-->

<!--当然其实代码不多，你直接自己魔改也行-->

<!--
类似判定滑动手势，拦截滑动、让父View不拦截滑动，消费滑动，协调父View一块消费滑动，还是得你来做，然后在恰当的时候调用，不过基类里加了渐变的方法

于是Google在View和Viewgorup中加入了一些方法，通过这些方法
-->

<!--
与一般的介绍不同，我不直接介绍这个机制的两个著名接口和两个著名` Helper `，而是继续从原理入手。

任何View都可以通过实现xx和xx接口（用1就行了，后面再解释）来声明自己支持 NestedScrolling 机制，实现了不同的接口代表自己在机制中扮演什么角色（但是，你可以也可以同时扮演两个角色）
-->

]]></content>
  </entry>
  
  <entry>
    <title type="html"><![CDATA[【透镜系列】看穿 > 触摸事件分发 >]]></title>
    <link href="http://blog.rubitree.com/15456255866253.html"/>
    <updated>2018-12-24T12:26:26+08:00</updated>
    <id>http://blog.rubitree.com/15456255866253.html</id>
    <content type="html"><![CDATA[
<p><img src="media/15456255866253/2019-01-02_01-18-46%20-2-.gif" alt=""/></p>

<span id="more"></span><!-- more -->

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<hr/>

<p></br><br/>
目录：</p>

<ul>
<li>
<a href="#toc_0">引子</a>
</li>
<li>
<a href="#toc_1">1. 五造轮子</a>
<ul>
<li>
<a href="#toc_2">1.1. 一造：直接传给目标View</a>
</li>
<li>
<a href="#toc_3">1.2. 二造：从里向外传给目标View</a>
</li>
<li>
<a href="#toc_4">1.3. 三造：区分事件类型</a>
</li>
<li>
<a href="#toc_5">1.4. 四造：增加外部事件拦截</a>
</li>
<li>
<a href="#toc_6">1.5. 五造：增加内部事件拦截</a>
</li>
<li>
<a href="#toc_7">1.6. 总结</a>
</li>
</ul>
</li>
<li>
<a href="#toc_8">2. 测试轮子</a>
<ul>
<li>
<a href="#toc_9">2.1. 测试框架</a>
</li>
<li>
<a href="#toc_10">2.2. 测试过程</a>
<ul>
<li>
<a href="#toc_11">场景一</a>
</li>
<li>
<a href="#toc_12">场景二</a>
</li>
</ul>
</li>
<li>
<a href="#toc_13">2.3. 测试结果</a>
</li>
</ul>
</li>
<li>
<a href="#toc_14">3. 实践</a>
</li>
<li>
<a href="#toc_15">4. 附录</a>
</li>
</ul>


<h2 id="toc_0">引子</h2>

<p>事件分发，我想大部分人都能说几句，哦，三大方法，哦，那段经典伪代码，哦，责任链...<br/>
但如果要让你完完整整捋一遍，你可能就开始支支吾吾了，只能想到啥说啥</p>

<p>这块的东西确实麻烦，说出来不怕吓到你，事件流到底怎么流与这些因素都有关系：是什么事件类型（<code>DOWN/MOVE/UP/CANCEL</code>）、在哪个视图层次（<code>Activity/ViewGroup/View</code>）、在哪个回调方法（<code>dispatch()/onIntercept()/onTouch()</code>）、回调方法给不同的返回值（<code>true/false/super.xxx</code>），甚至对当前事件的不同处理还会对同一事件流中接下来的事件造成不同影响<br/>
比如我可以问：重写某个<code>ViewGroup</code>里的<code>dispatchTouchEvent</code>方法，对<code>MOVE</code>事件返回<code>false</code>，整个事件分发过程会是什么样的？</p>

<p>于是就有人对这些情况分门别类进行总结，得到了很多规律，也画出了纷繁复杂的事件分发流程图：<br/>
<img src="media/15456255866253/15474670199849.jpg" alt="" style="width:150px;"/>× N     <img src="media/15456255866253/2019-01-14_19-54-59.png" alt="" style="width:230px;"/>  × N<br/>
甚至还有类似题图那样的动态流程图<em>（是的，吸引你进来的题图居然是反面教材，我也很心疼啊，画了我半个下午，结果并没有太大的帮助）</em></p>

<p>这些规律和流程图确实是对的，而且某种意义上也是非常清晰的，能帮助你在调试 Bug 的时候找到一点方向。<br/>
你或许可以奋发图强，把这些流程图和规律背下来，也能在需要的时候一通叽里呱啦背完大家大眼瞪小眼。<br/>
但它们并不能让你真正理解事件分发是什么样子，你可能某一次花费了大量的时间去看懂它们，但是「每次都能看明白！过一段时间又忘了！」<em>（某段有代表性的评论原话）</em></p>

<!--面对「是什么、为什么、怎么办」哲学三连，你只能抱拳告辞。-->

<!--这样的所谓的规则、规律之类的它是什么样的东西我能给你写一打-->

<!--这显然不太行，真正理解的人不是这个样子，你得把握其背后的逻辑，你得对其是什么样以及为什么是这样都了如指掌-->

<p>但讲道理，分发个触摸事件为什么会这么复杂呢？需要这么复杂吗？图啥呢？</p>

<p>于是，让我们回到起点，看看分发触摸事件到底是为了解决一个什么样的问题，有没有更简单的分发办法？然后看看当需求增加的时候，要怎么调整这个简单的分发策略？<br/>
看到最后你就会发现，原来一切是那么地自然。</p>

<p>所以，不用死记硬背，也不用急着去怼完整的事件分发流程，那么多复杂的逻辑和情况其实都是围绕着最根本的问题发展出来的，是随着需求的增加一步步变得复杂的，理解了演化过程，你自然会对其演化的结果了然于胸，想忘都忘不掉。<br/>
<strong>从根本问题出发，一切就会变得自然而然。</strong></p>

<p>艾维巴蒂，黑喂狗！<br/>
下面我将从最简单的需求开始思考方案、编写代码，然后一步步增加需求、调整方案、继续编写代码，争取造出一个麻雀虽小五脏俱全的事件分发框架。</p>

<!--我们希望它能满足实际的需求，然后看看它到底能复杂到哪儿去。-->

<h2 id="toc_1">1. 五造轮子</h2>

<h3 id="toc_2">1.1. 一造：直接传给目标View</h3>

<p>我们先实现一个最简单的需求：Activity 中有一堆层层嵌套的 View，有且只有最里边那个 View 会消费事件<br/>
<em>（黄色高亮 View 代表可以消费事件，蓝色 View 代表不消费事件）</em><br/>
<img src="media/15456255866253/15463677874030.jpg" alt="" style="width:350px;"/></p>

<p>思考方案：</p>

<ol>
<li>首先事件从哪儿来，肯定得从父亲那来，因为子View被包裹在里面，没有直接与外界通信的办法，而实际中<code>Activity</code>连接着根View<code>DecorView</code>，它是通往外界的桥梁，能接收到屏幕硬件发送过来的触摸事件</li>
<li>所以事件是从<code>Activity</code>开始，经过一层一层 ViewGroup ，传到最里边的 View</li>
<li>这时只需要一个从外向里传递事件的<code>passEvent(ev)</code>方法，父亲一层层往里调，能把事件传递过去，就完成了需求</li>
</ol>

<p>示意图<br/>
<img src="media/15456255866253/15463678459112.jpg" alt="" style="width:400px;"/></p>

<p>麻雀代码：<br/>
<em>（本文代码使用Kotlin编写，核心代码也提供了<a href="https://github.com/RubiTree/DispatchTouchEventTutorial/blob/master/app/src/main/java/com/rubitree/dispatchtoucheventtutorial/sparrowdispatch/sparrow5_java/MActivity.java">Java版本</a>）</em></p>

<pre><code class="language-kotlin">open class MView {
    open fun passEvent(ev: MotionEvent) {
        // do sth
    }
}
    
class MViewGroup(private val child: MView) : MView() {
    override fun passEvent(ev: MotionEvent) {
        child.passEvent(ev)
    }
}
</code></pre>

<ol>
<li>暂时把<code>Activity</code>当成<code>MViewGroup</code>处理也没有问题</li>
<li>为什么是<code>MViewGroup</code>继承<code>MView</code>而不是反过来，因为 <code>MView</code> 是不需要 <code>child</code> 字段的</li>
</ol>

<h3 id="toc_3">1.2. 二造：从里向外传给目标View</h3>

<p>然后我们增加一条需求，让情况复杂一点：<code>Activity</code>中有一堆层层嵌套的View，有好几个叠着的View能处理事件<br/>
<img src="media/15456255866253/15463678689560.jpg" alt="" style="width:350px;"/></p>

<p>同时需要增加一条设计原则：<strong>用户的一次操作，只能被一个View真正处理（消费）</strong></p>

<ol>
<li>要求这条原则是为了让操作的反馈符合用户直觉</li>
<li>很容易理解，正常情况下人只会想一次就做一件事

<ol>
<li>比如一个列表条目，列表可以点击进入详情，列表上还有个编辑按钮，点击可以编辑条目

<ol>
<li>这是一个上下两个View都能点击的场景，但用户点一个地方，肯定只想去做一件事，要么进入详情，要么是编辑条目，如果你点编辑结果跳了两个页面，那肯定是不合适的</li>
</ol></li>
<li>再比如在一个可点击Item组成的列表里（比如微信的消息界面），Item可以点击进入某个聊天，列表还能滑动上下查看

<ol>
<li>如果你让Item和列表都处理事件，那在你滑动的时候，你可能得跳一堆你不想去的聊天页面</li>
</ol></li>
</ol></li>
</ol>

<!--（@@@可能有好事者会举手问，你个乡巴佬没见过coordarylayout吗。首先，其实还是一个View消费，一个View消费对开发者的好处是，减少代码的耦合，coordarylayout是一个View消费事件作出自己的反应后，其他相关的依赖View会作出相应的变换，另外其实这是用在开发者确实需要几个View配合作出符合用户直觉的效果的场景，而不是你瞎几把写出的大家乱搞的场景）-->

<p>如果使用第一次试造的框架，要遵守这条原则，就需要在每一个可以处理事件的View层级，判断出自己要处理事件后，不继续调用<code>child</code>的<code>passEvent()</code>方法了，保证只有自己处理了事件。<br/>
但如果真这样实现了，在大部分场景下会显得怪怪的，因为处理事件的顺序不对：</p>

<ol>
<li>比如还是上面的列表，当用户点击按钮想编辑条目的时候，点击事件先传到条目，如果你在条目中判断需要事件，然后把事件消费了不传给子View，用户就永远点不开编辑条目了</li>
<li>而且换个角度看更加明显，用户肯定希望点哪，哪儿最靠上、离手指最近的东西被触发</li>
</ol>

<p>所以实现新增需求的一个关键是：<strong>找到那个适合处理事件的View</strong>，而我们通过对业务场景进行分析，得到答案是：<strong>那个最里面的View适合处理事件</strong></p>

<p>这就不能是等<code>parent</code>不处理事件了才把事件传给<code>child</code>，应该反过来，你需要事件的处理顺序是从里向外：里边的<code>child</code>不要事件了，才调用<code>parent</code>的<code>passEvent()</code>方法把事件传出来。<br/>
于是得加一条向外的通道，只能在这条向外的通道上处理事件，前面向里的通道什么都不干，只管把事件往里传。<br/>
所以这时你有了两条通道，改个名字吧，向里传递事件是<code>passIn()</code>方法，向外传递并处理事件是<code>passOut()</code>方法。</p>

<p>示意图<br/>
<img src="media/15456255866253/15463678930105.jpg" alt="" style="width:400px;"/></p>

<p>麻雀代码：</p>

<pre><code class="language-kotlin">open class MView {
    var parent: MView? = null

    open fun passIn(ev: MotionEvent) {
        passOut(ev)
    }

    open fun passOut(ev: MotionEvent) {
        parent?.passOut(ev)
    }
}

class MViewGroup(private val child: MView) : MView() {
    init {
        child.parent = this // 示意写法
    }

    override fun passIn(ev: MotionEvent) {
        child.passIn(ev)
    }
}
</code></pre>

<p>这段代码没有问题，非常简单，但是它对需求意图的表达不够清晰，增加了框架的使用难度</p>

<ol>
<li>如前所述，我们希望<code>passIn()</code>的时候只传递事件，希望在<code>passOut()</code>的时候每个View决定是否要处理事件，并进行处理，而且在处理事件后，不再调用<code>parent</code>的<code>passOut()</code>方法把事件传出来</li>
<li>你会发现，这其中包含了两类职责：

<ol>
<li>一类是事件传递控制逻辑，另一类是事件处理钩子</li>
<li>其中事件传递控制逻辑基本不会变化，但事件处理的钩子中可能做任何事情</li>
</ol></li>
<li>我们需要把不同职责的代码分开，更需要把变化的和不变的分开，减少框架使用者的关注点</li>
</ol>

<p>于是我们用一个叫做<code>dispatch()</code>的方法单独放事件传递的控制逻辑，用一个叫做<code>onTouch()</code>的方法作为事件处理的钩子，而且钩子有一个返回值，表示钩子中是否处理了事件：</p>

<pre><code class="language-kotlin">open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = child.dispatch(ev)
        if (!handled) handled = onTouch(ev)

        return handled
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
</code></pre>

<p>这样写完，整个行为其实没有变化，但你会发现：</p>

<ol>
<li>控制逻辑集中在<code>dispatch()</code>中，一目了然</li>
<li><code>onTouch()</code>单纯是一个钩子，框架使用者只需要关心这个钩子和它的返回值，不用太关心控制流程</li>
<li>另外，连<code>parent</code>也不需要了</li>
</ol>

<h3 id="toc_4">1.3. 三造：区分事件类型</h3>

<p>上文的实现看上去已经初具雏形了，但其实连开始提的那条原则都没实现完，因为原则要求一次操作只能有一个 View 进行处理，而我们实现的是一个触摸事件只能有一个View进行处理。<br/>
这里就涉及到一次触摸操作和一个触摸事件的区别：</p>

<ol>
<li>假设还没有触摸事件的概念，我们要怎么区分一次触摸操作呢？

<ol>
<li>把触摸操作细分一下，大概有按下动作、抬起动作、与屏幕接触时的移动和停留动作</li>
<li>很容易想到，要区分两次触摸操作，可以通过按下和抬起动作进行区分，按下动作开始了一次触摸操作，抬起动作结束了一次触摸，按下和抬起中间的移动和停留都属于这一次触摸操作，至于移动和停留是否要区分，目前没有看到区分的必要，可以都作为触摸中来处理</li>
</ol></li>
<li>于是在一次触摸操作中就有了三种动作的类型：<code>DOWN/UP/ING</code>，其中<code>ING</code>有点不够专业，改个名字叫<code>MOVE</code>吧</li>
<li>而每个触摸动作会在软件系统中产生一个同样类型的触摸事件</li>
<li>所以最后，一次触摸操作就是由一组从<code>DOWN</code>事件开始、中间是多个<code>MOVE</code>事件、最后结束于<code>UP</code>事件的事件流组成</li>
</ol>

<p>于是设计原则更确切地说就是：<strong>一次触摸产生的事件流，只能被一个View消费</strong></p>

<p>在上次试造的基础上把一个事件变成一个组事件流，其实非常简单：处理<code>DOWN</code>事件时跟前面处理一个事件时一样，但需要同时记住<code>DOWN</code>事件的消费对象，后续的<code>MOVE/UP</code>事件直接交给它就行了</p>

<p>麻雀代码：</p>

<pre><code class="language-kotlin">open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
        
            handled = child.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) handled = onTouch(ev)
        } else {
            if (isChildNeedEvent) handled = child.dispatch(ev)
            if (!handled) handled = onTouch(ev)
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
            
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
</code></pre>

<p>代码好像增加了很多，其实只多做了两件事：</p>

<ol>
<li>增加了一个<code>isChildNeedEvent</code>状态，对是子View是否处理了<code>DOWN</code>事件进行记录，并在其他触摸事件时使用这个状态</li>
<li>在收到<code>DOWN</code>事件的最开始和收到<code>UP</code>事件的最后，重置状态</li>
</ol>

<p>此时框架使用者还是只需要关心<code>onTouch()</code>钩子，在需要处理事件时进行处理并返回<code>true</code>，其他事情框架都做好了。</p>

<h3 id="toc_5">1.4. 四造：增加外部事件拦截</h3>

<p>上面的框架已经能完成基本的事件分发工作了，但下面这个需求，你尝试一下用现在框架能实现吗？<br/>
需求：<strong>在可滑动View中有一个可点击View，需要让用户即使按下的位置是可点击View，再进行滑动时，也可以滑动外面的的可滑动View</strong>。<br/>
<img src="media/15456255866253/15463679356863.jpg" alt="" style="width:380px;"/><br/>
这个需求其实非常常见，比如所有「条目可点击的滑动列表」就是这样的（微信/QQ聊天列表）。</p>

<p>假如使用上面的框架：</p>

<ol>
<li>可滑动View会先把事件传到里边的可点击View</li>
<li>可点击View一看来事件了，我又能点击，那舍我其谁啊</li>
<li>然后外面的可滑动View就永远无法处理事件，也就无法滑动</li>
</ol>

<p>所以直接使用现在的模型去实现的「条目可点击的滑动列表」会永远滑动不了。</p>

<p>那怎么办呢？</p>

<ol>
<li>难道要让里面的可点击View去感知一下（层层往上找），自己是不是被一个能消费事件的View包裹？是的话自己就不消费事件了？

<ol>
<li>这肯定是不行的，先不说子View层层反向遍历父亲是不是个好实现，至少不能外面是可以滑动的，里边View的点击事件就全部失效</li>
</ol></li>
<li>或者我们调整<code>dispatch()</code>方法在传入事件过程中的人设，让它不是只能往里传递事件，而是在自己能消费事件的时候把事件给自己

<ol>
<li>这肯定也是不行的，跟第一个办法的主要问题一样</li>
</ol></li>
</ol>

<p>直接想实现觉得到处是矛盾，找不到突破口，那就从头开始吧，从什么样的触摸反馈是用户觉得自然的出发，看看这种符合直觉的反馈方案是否存在，找出来它是什么，再考虑我们要怎么实现：</p>

<ol>
<li>当用户面对一个滑动View里有一个可点击View，当他摸在可点击View上时，他是要做什么？</li>
<li>显然，只有两个可能性，要么用户想点这个可点击View，要么用户想滑动这个可滑动View</li>
<li>那么，当用户刚用手指接触的时候，也就是<code>DOWN</code>事件刚来的时候，能判断用户想干什么吗？很抱歉，不能</li>
<li>所以，客观条件下，你就是不可能在<code>DOWN</code>事件传过来的时候，判断出用户到底想做什么，于是两个View其实都不能确定自己是否要消费事件</li>
</ol>

<p><del>我*，这不傻*了吗，还搞什么GUI啊，大家都用命令行吧</del><br/>
等等，不要着急，GUI还是得搞的，不搞没饭吃的我跟你讲，所以你还是得想想，想尽办法去实现。</p>

<p>你先忘记前面说的原则，你想想，不考虑其他因素，也不是只能用<code>DOWN</code>事件，只要你能判断用户的想法就行，你有什么办法？</p>

<ol>
<li>办法肯定是有的，你可以<strong>多等一会，看用户接下来的行为能匹配哪种操作模式</strong>

<ol>
<li>点击操作的模式是这样：用户先<code>DOWN</code>，然后<code>MOVE</code>很小一段，也不会MOVE出这个子View，关键是比较短的时间就<code>UP</code></li>
<li>滑动操作的模式是这样：用户先<code>DOWN</code>，然后开始<code>MOVE</code>，这时候可能会MOVE出这个子View，也可能不，但关键是比较长的时间也没有在<code>UP</code>，一直是在<code>MOVE</code></li>
</ol></li>
<li>所以你的结论是，<strong>只有<code>DOWN</code>不行，还得看接下来的事件流，得走着瞧</strong></li>
<li>再多考虑个长按的情况，总结就是：

<ol>
<li>如果在某个时间内<code>UP</code>，就是点击里边的View</li>
<li>如果比较长的时间<code>UP</code>，但没怎么<code>MOVE</code>，就是长按里边的View</li>
<li>如果在比较短的时间<code>MOVE</code>比较长的距离，就是滑动外面的View</li>
</ol></li>
</ol>

<p>看上去这个目标 View 判定方案很不错，安排得明明白白，但我们现有的事件处理框架实现不了这样的判定方案，至少存在以下两个冲突：</p>

<ol>
<li>因为子View和父View都无法在<code>DOWN</code>的时候判断当前事件流是不是该给自己，所以一开始它们都只能返回<code>false</code>。但为了能对后续事件做判断，你希望事件继续流过它们，按照当前框架的逻辑，你又不能返回<code>false</code>。</li>
<li>假设事件会流过它们，当事件流了一会儿后，父 View 判断出这符合自己的消费模式啊，于是想把事件给自己消费，但此时子 View 可能已经在消费事件了，而目前的框架是做不到阻止子 View 继续消费事件的</li>
</ol>

<p>所以要解决上述的冲突，就肯定要对上一版的事件处理框架进行修改，而且看上去一不小心就会大改</p>

<ol>
<li>首先看第二个冲突，解决它的一个直接方案是：调整 <code>dispatch()</code> 方法在传入事件过程中的人设，让它不是只传递事件了，还可以在往里传递事件前进行拦截，能够看情况拦截下事件并交给自己的 <code>onTouch()</code> 处理</li>
<li>基于这个解决方案，大概有以下两个改动相对小的方案调整思路：

<ol>
<li>思路一：

<ol>
<li>当事件走到可滑动父View的时候，它先拦截并处理事件，而且还把事件给攒着</li>
<li>当经过了几个事件

<ol>
<li>如果判断出符合自己的消费模式，那就直接开始自己消费了，也不用继续攒事件了</li>
<li>如果判断出不是自己的消费模式，再把所有攒着的事件一股脑给子 View，触发里边的点击操作</li>
</ol></li>
</ol></li>
<li>思路二：

<ol>
<li>所有的 View 只要可能消费事件，就在<code>onTouch()</code>里对<code>DOWN</code>事件返回<code>true</code>，不管是否识别出当前属于自己的消费模式</li>
<li>当事件走到到可滑动父 View 的时候，它先把事件往里传，里边可能会处理事件，可能不会，可滑动父 View 都暂时不关心</li>
<li>然后看子 View 是否处理事件

<ol>
<li>假如子 View 不处理事件，那啥问题没有，父 View 直接处理事件就好了</li>
<li>假如子 View 处理事件，可滑动父View就会绷紧神经暗中观察伺机而动，观察事件是不是符合自己的消费模式，一旦发现符合，它就把事件流拦截下来，即使子View也在处理事件，它也不往里<code>disptach</code>事件了，而是直接给自己的<code>onTouch()</code></li>
</ol></li>
</ol></li>
</ol></li>
<li>两个思路总结一下：

<ol>
<li>思路一：外面的父 View 先拦事件，如果判断拦错了，再把事件往里发</li>
<li>思路二：外面的父 View 先不拦事件，在判断应该拦的时候，突然把事件拦下来</li>
</ol></li>
<li>这两个思路都要对当前框架做改变，看似差不多，但其实还是有比较明显的优劣的

<ol>
<li>思路一问题比较明显：

<ol>
<li>父 View 把事件拦下来了，然后发现拦错了再给子 View，但其实子 View 又并不一定能消费事件，这不就是白做一步吗。等到子View不处理事件，又把事件们还给父View，父View还得继续处理事件。整个过程不仅繁琐，而且会让开发者感觉到别扭</li>
<li>所以这个思路不太行，还得是把事件先给子View</li>
</ol></li>
<li>思路二就相对正常多了，只有一个问题（下一节再讲，你可以猜一猜，这里我先当没发现），而且框架要做的改变也很少：

<ol>
<li>增加一个拦截方法<code>onIntercept()</code>在父 View 往里<code>dispatch</code>事件前，开发者可以覆写这个方法，加入自己的事件模式分析代码，并且可以在确定要拦截的时候进行拦截

<ol>
<li>把分析拦截逻辑抽成一个方法非常合理：什么时候拦，什么时候不拦，内里的逻辑很多，但对外暴露的 API 可以很小，非常适合抽出去</li>
</ol></li>
<li>在确定自己要拦截事件的时候，即使里边在一开始消费了事件，也不把事件往里传了，而是直接给自己的<code>onTouch()</code></li>
</ol></li>
</ol></li>
</ol>

<p>示意图：<br/>
<img src="media/15456255866253/15463679714860.jpg" alt="" style="width:400px;"/></p>

<p>于是使用思路二，在「三造」的基础上，修改得到以下代码：</p>

<pre><code class="language-kotlin">open class MView {
    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

class MViewGroup(private val child: MView) : MView() {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            if (onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                if (onIntercept(ev)) {
                    isSelfNeedEvent = true
                    handled = onTouch(ev)
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP) {
            clearStatus()
        }
        
        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }
}
</code></pre>

<p>写的过程中增加了一些对细节的处理：</p>

<ol>
<li>不仅是在<code>DOWN</code>事件的<code>dispatch()</code>前需要拦截，在后续事件中，也需要加入拦截，否则无法实现中途拦截的目标</li>
<li>在某一个事件判断拦截之后，还需要在后续事件中再判断一次是否要拦截吗？

<ol>
<li>完全不需要，我们希望的就是在一次触摸中，尽可能只有1个对象去消费事件，决定是你了，那就不要变</li>
<li>所以增加一个<code>isSelfNeedEvent</code>记录自己是否拦截过事件，如果拦截过，后续事件直接就交给自己处理</li>
</ol></li>
<li>在后续事件时，子 View 没有处理事件，外面也不会再处理了，同样因为只能有一个 View 处理（Actvity会处理这样的事件，后面会提到）</li>
</ol>

<p>这一下代码是不是看上去瞬间复杂了，但其实只是增加了一个事件拦截机制，对比上一次试造的轮子，会更容易理解。（要是 Markdown 支持代码块内自定义着色就好了）</p>

<p>而且对于框架的使用者来说，关注点还是非常少</p>

<ol>
<li>重写<code>onIntercept()</code>方法，判断什么时候需要拦截事件，需要拦截时返回<code>true</code></li>
<li>重写<code>onTouch()</code>方法，如果处理了事件，返回<code>true</code></li>
</ol>

<h3 id="toc_6">1.5. 五造：增加内部事件拦截</h3>

<p>上面的处理思路虽然实现了需求，但可能会导致一个问题：里边的子 View 接收了一半的事件，可能都已经开始处理并做了一些事情，父 View 忽然就不把后续事件给它了，会不会违背用户操作的直觉？甚至出现更奇怪的现象？</p>

<!--但会造成一个非常明显的与原则违背的情况，那就是可能不止一个View会消费到事件，两个View，三个View，甚至极端情况多个View都有可能
（因为两个和多个是一样的，下文只用两个View的情况举例）-->

<p>这个问题确实比较麻烦，分两类情况讨论</p>

<ol>
<li>里边的 View 接收了一半事件，但还没有真正开始反馈交互，或者在进行可以被取消的反馈

<ol>
<li>比如对于一个可点击的View，View的默认实现是只要被touch了，就会有<code>pressed</code>状态，如果你设置了对应的<code>background</code>，你的 View 就会有高亮效果</li>
<li>这种高亮即使被中断也没事，不会让用户感觉到奇怪，不信你自己试试微信的聊天列表</li>
<li>但一个值得注意的点是，如果你只是直接不发送<code>MOVE</code>事件了，这会有问题，就这个按下高亮的例子，如果你只是不传<code>MOVE</code>事件了，那谁来告诉里边的子View取消高亮呢？所以你需要在中断的时候也传一个结束事件

<ol>
<li>但是，你能直接传一个<code>UP</code>事件吗？也是不行的，因为这样就匹配了里边点击的模式了，会直接触发一个点击事件，这显然不是我们想要的</li>
<li>于是外面需要给一个新的事件，这个事件的类型就叫取消事件好了<code>CANCEL</code></li>
</ol></li>
<li>总结一下，对于这种简单的可被取消情况，你可以这样去处理：

<ol>
<li>在确定要拦截的时候，在把真正的事件转发给自己的<code>onTouch()</code>的同时，另外生成一个新的事件发给自己的子View，事件类型是<code>CANCEL</code>，它将是子View收到的最后一个事件</li>
<li>子View可以在收到这个事件后，对当前的一些行为进行取消</li>
</ol></li>
</ol></li>
<li>里边的View接收了一半事件，已经开始反馈交互了，这种反馈最好不要去取消它，或者说取消了会显得很怪

<ol>
<li>这个时候，事情会复杂一些，而且这个场景发生的远比你想象中的多，形式也多种多样，不处理好的后果也比只是让用户感觉上奇怪要严重得多，可能会有的功能会实现不了，下面举两个例子

<ol>
<li>在<code>ViewPager</code>里有三个page，page里是<code>ScrollView</code>，<code>ViewPager</code>可以横向滑动，page里的<code>ScrollView</code>可以竖向滑动

<ol>
<li>如果按前面逻辑，当<code>ViewPager</code>把事件给里边<code>ScrollView</code>之后，它也会偷偷观察，如果你一直是竖向滑动，那没话说，<code>ViewPager</code>不会触发拦截事件</li>
<li>但如果你竖着滑着滑着，手抖了，开始横滑（或者只是斜滑），<code>ViewPager</code>就会开始紧张，想「组织终于决定是我了吗？真的假的，那我可就不客气了」，于是在你斜滑一定距离之后，忽然发现，你划不动<code>ScrollView</code>了，而<code>ViewPager</code>开始动</li>
<li>原因就是<code>ScrollView</code>的竖滑被取消了，<code>ViewPager</code>把事件拦下来，开始横滑</li>
<li>这个体验还是比较怪的，会有种过于灵敏的感觉，会让用户只能小心翼翼地滑动</li>
</ol></li>
<li>在一个<code>ScrollView</code>里有一些按钮，按钮有长按事件，长按再拖动就可以移动按钮

<ol>
<li>（更常见的例子是一个列表，里边的条目可以长按拖动）</li>
<li>同样按前面的逻辑，当你长按后准备拖动按钮时，你怎么保证不让<code>ScrollView</code>把事件拦下来呢？</li>
</ol></li>
</ol></li>
<li>所以这类问题是一定要解决的，但要怎么解决呢

<ol>
<li>还是先从业务出发，从用户的角度看，当里边已经开始做一些特殊处理了，外面应不应该把事件抢走？

<ol>
<li>不应该对吧，OK，解决方针就是不应该让外边的View抢事件</li>
</ol></li>
<li>所以接下来的问题是：谁先判断出外边的View不该抢事件，里边的子View还是外边的父View？然后怎么不让外边的View抢？

<ol>
<li>首先，肯定是里边的View做出判断：这个事件，真的，外边的View你最好别抢，要不用户不开心了</li>
<li>然后里边就得告知外边，你别抢了，告知可以有几个方式

<ol>
<li>外边抢之前问一下里边，我能不能抢</li>
<li>里边在确定这个事件不能被抢之后，从<code>dispatch</code>方法返回一个特别的值给外边（之前只是<code>true</code>和<code>false</code>，现在要加一个）</li>
<li>里边通过别的方式通知外边，你不要抢</li>
</ol></li>
<li>讲道理，我觉得三个方式都行，但第三个方式最为简单直接，而且对框架没有过大的改动，Android也使用了这个方式，父View给子View提供了一个方法<code>requestDisallowInterceptTouchEvent()</code>，子View调用它改变父View的一个状态，同时父View每次在准备拦截前都会判断这个状态（当然这个状态只对当前事件流有效）</li>
<li>然后，这个情况还得再注意一点，它应该是向上递归的，也就是，在复杂的情况中，有可能有多个上级在暗中观察，当里边的View决定要处理事件而且不准备交出去的时候，外面所有的暗中观察的父View都应该把脑袋转回去</li>
</ol></li>
</ol></li>
</ol></li>
</ol>

<!--（最后，这种方案其实一并解决了一个前面遗漏的问题，那就是当内部View即使在`dispatch()`方法返回true，也不会影响外部View拦截事件，导致内部View没有办法申明自己要消费事件无法主动拦截事件）-->

<!--但如果你竖着滑着滑着，手抖了，开始横滑（或者只是斜滑），`ViewPager`就会开始紧张，想「主人你这是要临幸我吗？真的假的，那我可就不客气了」，于是在你斜滑一定距离之后，忽然发现，你划不动`ScrollView`了，而`ViewPager`开始动-->

<p>所以，连同上一次试造，总结一下</p>

<ol>
<li>对于多个可消费事件的View进行嵌套的情况，怎么判定事件的归属会变得非常麻烦，无法立刻在<code>DOWN</code>事件时就确定，只能在后续的事件流中进一步判断</li>
<li>于是在没判断归属的时候，先由里边的子View消费事件，外面暗中观察，同时两方一块对事件类型做进一步匹配，并准备在匹配成功后对事件流的归属进行抢拍</li>
<li>抢拍是先抢先得

<ol>
<li>父亲先抢到，发个<code>CANCEL</code>事件给儿子就完了</li>
<li>儿子先抢到，就得大喊大叫，撒泼耍赖，爸爸们行行好吧，最后得以安心处理事件</li>
</ol></li>
</ol>

<p>另外有几个值得一提的地方：</p>

<ol>
<li>这种先抢先得的方式感觉上有点乱来是吧，但目前也没有想到更好的办法了，一般都是开发者自己根据实际用户体验调整，让父亲或儿子在最适合的时机准确及时地抢到应得的事件</li>
<li>父View在拦截下事件后，把接下来的事件传给自己的<code>onTouch()</code>后，<code>onTouch()</code>只会收到后半部分的事件，这样会不会有问题呢？

<ol>
<li>确实直接给后半部分会有问题，所以一般情况是，在没拦截的时候就做好如果要处理事件的一些准备工作，以便之后拦截事件了，只使用后半部分事件也能实现符合用户直觉的反馈</li>
</ol></li>
</ol>

<!--
1. 源码对于在可滑动View中的子View这种情况，还进一步做了一处体验上的优化：
    - 正常里边的子View在按下就会直接显示按下状态，只是只有在马上抬起时才表现为点击，如果只是`MOVE`不抬起，那就是滑外面了，就取消按下状态
    - 这样可行，但Android觉得这个体验还是不够好，于是会让里边的子View感知自己是不是在一个可滑动View里边，如果是的话，它不会下按下直接显示按下状态，而是稍微晚那么一点点显示出啦，提升了一点用户体验-->

<p>在「四造」的基础上，修改得到以下代码：</p>

<pre><code class="language-kotlin">interface ViewParent {
    fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)
}

open class MView {
    var parent: ViewParent? = null

    open fun dispatch(ev: MotionEvent): Boolean {
        return onTouch(ev)
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}

open class MViewGroup(private val child: MView) : MView(), ViewParent {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false
    private var isDisallowIntercept = false

    init {
        child.parent = this
    }

    override fun dispatch(ev: MotionEvent): Boolean {
        var handled = false
        
        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()
            
            // add isDisallowIntercept
            if (!isDisallowIntercept &amp;&amp; onIntercept(ev)) {
                isSelfNeedEvent = true
                handled = onTouch(ev)
            } else {
                handled = child.dispatch(ev)
                if (handled) isChildNeedEvent = true

                if (!handled) {
                    handled = onTouch(ev)
                    if (handled) isSelfNeedEvent = true
                }
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                // add isDisallowIntercept
                if (!isDisallowIntercept &amp;&amp; onIntercept(ev)) {
                    isSelfNeedEvent = true

                    // add cancel
                    val cancel = MotionEvent.obtain(ev)
                    cancel.action = MotionEvent.ACTION_CANCEL
                    handled = child.dispatch(cancel)
                    cancel.recycle()
                } else {
                    handled = child.dispatch(ev)
                }
            }
        }
        
        if (ev.actionMasked == MotionEvent.ACTION_UP 
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }
        
        return handled
    }
    
    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
        isDisallowIntercept = false
    }

    override fun onTouch(ev: MotionEvent): Boolean {
        return false
    }

    open fun onIntercept(ev: MotionEvent): Boolean {
        return false
    }

    override fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean) {
        this.isDisallowIntercept = isDisallowIntercept
        parent?.requestDisallowInterceptTouchEvent(isDisallowIntercept)
    }
}
</code></pre>

<p>这次改动主要是增加了发出<code>CANCEL</code>事件和<code>requestDisallowInterceptTouchEvent</code>机制</p>

<ol>
<li>在发出<code>CANCEL</code>事件时有一个细节：没有在给 <code>child</code> 分发<code>CANCEL</code>事件的同时继续把原事件分发给自己的<code>onTouch</code>

<ol>
<li>这是源码中的写法，不是我故意的，可能是为了让一个事件也只能有一个View处理，避免出现bug</li>
</ol></li>
<li>实现<code>requestDisallowInterceptTouchEvent</code>机制时，增加了<code>ViewParent</code>接口

<ol>
<li>不使用这种写法也行，但使用它从代码整洁的角度看会更优雅，比如避免反向依赖，而且这也是源码的写法，于是直接搬来了</li>
</ol></li>
</ol>

<p>虽然目前整个框架的代码有点复杂，但对于使用者来说，依然非常简单，只是在上一版框架的基础上增加了：</p>

<ol>
<li>如果View判断自己要消费事件，而且执行的是不希望被父View打断的操作时，需要立刻调用父View的<code>requestDisallowInterceptTouchEvent()</code>方法</li>
<li>如果在<code>onTouch</code>方法中对事件消费并且做了一些操作，需要注意在收到<code>CANCEL</code>事件时，对操作进行取消</li>
</ol>

<p>到这里，事件分发的主要逻辑已经讲清楚了，不过还差一段 Activity 中的处理，其实它做的事情类似ViewGroup，只有这几个区别：</p>

<ol>
<li>不会对事件进行拦截</li>
<li>只要有子View没有处理的事件，它都会交给自己的<code>onTouch()</code></li>
</ol>

<p>所以不多讲了，直接补上Activity的麻雀：</p>

<pre><code class="language-kotlin">open class MActivity(private val childGroup: MViewGroup) {
    private var isChildNeedEvent = false
    private var isSelfNeedEvent = false

    open fun dispatch(ev: MotionEvent): Boolean {
        var handled = false

        if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
            clearStatus()

            handled = childGroup.dispatch(ev)
            if (handled) isChildNeedEvent = true

            if (!handled) {
                handled = onTouch(ev)
                if (handled) isSelfNeedEvent = true
            }
        } else {
            if (isSelfNeedEvent) {
                handled = onTouch(ev)
            } else if (isChildNeedEvent) {
                handled = childGroup.dispatch(ev)
            }

            if (!handled) handled = onTouch(ev)
        }

        if (ev.actionMasked == MotionEvent.ACTION_UP
            || ev.actionMasked == MotionEvent.ACTION_CANCEL) {
            clearStatus()
        }

        return handled
    }

    private fun clearStatus() {
        isChildNeedEvent = false
        isSelfNeedEvent = false
    }

    open fun onTouch(ev: MotionEvent): Boolean {
        return false
    }
}
</code></pre>

<h3 id="toc_7">1.6. 总结</h3>

<p>到这里，我们终于造好了一个粗糙但不劣质的轮子，源码的主要逻辑与它的区别不大，具体区别大概有：<code>TouchTarget</code>机制、多点触控机制、NestedScrolling 机制、处理各种 listener、结合View的状态进行处理等，相比主要逻辑，它们就没有那么重要了，大家可以自行阅读源码，之后有空也会写关于多点触控和<code>TouchTarget</code>的内容 <del>（挖坑预警）</del></p>

<p>轮子的完整代码可以在在<a href="https://github.com/RubiTree/DispatchTouchEventTutorial/blob/master/app/src/main/java/com/rubitree/dispatchtoucheventtutorial/sparrowdispatch/sparrow5/SparrowDispatch.kt">这里</a>查看（<a href="(https://github.com/RubiTree/DispatchTouchEventTutorial/blob/master/app/src/main/java/com/rubitree/dispatchtoucheventtutorial/sparrowdispatch/sparrow5_java/MActivity.java)">Java版本</a>）<br/>
这个轮子把源码中与事件分发相关的内容剥离了出来，能看到：</p>

<ol>
<li>相比源码，这份代码足够短足够简单，那些跟事件分发无关的东西通通不要来干扰我

<ol>
<li>长度总共不超过150行，剔除了所有跟事件分发无关的代码，并且把一些因为其他细节导致写得比较复杂的逻辑，用更简单直接的方式表达了</li>
</ol></li>
<li>相比那段经典的事件分发伪代码（见附录），这份代码又足够详细，详细到能告诉你所有你需要知道的事件分发的具体细节

<ol>
<li>那段经典伪代码只能起到提纲挈领的作用，而这份麻雀代码虽然极其精简但它五脏俱全，全到可以直接跑 —— 你可以用它进行为伪布局，然后触发触摸事件</li>
</ol></li>
</ol>

<p>但轮子不是最重要的，最重要的是整个演化的过程。</p>

<p>所以回头看，你会发现事件分发其实很简单，它的关键不在于「不同的事件类型、不同的View种类、不同的回调方法、方法不同的返回值」对事件分发是怎么影响的。<br/>
关键在于<strong>「它要实现什么功能？对实现效果有什么要求？使用了什么解决方案？」</strong>，从这个角度，就能清晰而且简单地把事件分发整个流程梳理清楚。</p>

<p>事件分发要实现的功能是：对用户的触摸操作进行反馈，使之符合用户的直觉。</p>

<p>从用户的直觉出发能得到这么两个要求：</p>

<ol>
<li>用户的一次操作只有一个View去消费</li>
<li>让消费事件的View跟用户的意图一致</li>
</ol>

<p>第二个要求是最难实现的，如果有多个View都可以消费触摸事件，怎么判定哪个View更适合消费，并且把事件交给它。<br/>
我们使用了一套简单但有效的先到先得策略，让内外的可消费事件的View拥有近乎平等的竞争消费者的资格：它们都能接收到事件，并在自己判定应该消费事件的时候去发起竞争申请，申请成功后事件就全部由它消费。</p>

<p><em>（转载请注明作者：RubiTree，地址：blog.rubitree.com ）</em></p>

<h2 id="toc_8">2. 测试轮子</h2>

<p>可能有人会问，听你纸上谈兵了半天，你讲的真的跟源码一样吗，这要是不对我不是亏大了。<br/>
问的好，所以接下来我会使用一个测试事件分发的日志测试框架对这个小麻雀进行简单的测试，还会有实践部分真刀真枪地把上面讲过的东西练起来。</p>

<h3 id="toc_9">2.1. 测试框架</h3>

<p>测试的思路是通过在每个事件分发的钩子中打印日志来跟踪事件分发的过程。<br/>
于是就需要在不同的 View 层级的不同钩子中，针对不同的触摸事件进行不同的操作，以制造各种事件分发的场景。</p>

<p>为了减少重复代码简单搭建了一个测试框架（所有代码都能<a href="https://github.com/RubiTree/DispatchTouchEventTutorial">在此处查看</a>），包括一个可以代理 View 中这些的操作的接口<code>IDispatchDelegate</code>及其实现类，和一个<code>DispatchConfig</code>统一进行不同的场景的配置。<br/>
之后创建了使用统一配置和代理操作的 真实控件们<code>SystemViews</code> 和 我们自己实现的麻雀控件们<code>SparrowViews</code>。</p>

<p>在<code>DispatchConfig</code>中配置好事件分发的策略后，直接启动<code>SystemViews</code>中的<code>DelegatedActivity</code>，进行触摸，使用关键字<code>TouchDojo</code>过滤，就能得到事件分发的跟踪日志。<br/>
同时，运行<code>SparrowActivityTest</code>中的<code>dispatch()</code>测试方法，也能得到麻雀控件的事件分发跟踪日志。</p>

<h3 id="toc_10">2.2. 测试过程</h3>

<h4 id="toc_11">场景一</h4>

<p>先配置策略，模拟<code>View</code>和<code>ViewGroup</code>都不消费事件的场景：</p>

<pre><code class="language-kotlin">fun getActivityDispatchDelegate(layer: String = &quot;Activity&quot;): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = &quot;ViewGroup&quot;): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewDispatchDelegate(layer: String = &quot;View&quot;): IDispatchDelegate {
    return DispatchDelegate(layer)
}
</code></pre>

<p>能看到打印的事件分发跟踪日志：</p>

<pre><code class="language-log">[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept_BE |type:down
|layer:SViewGroup |on:Intercept_AF |result(super):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch_BE |type:down
|layer:SView |on:Touch_AF |result(super):false |type:down
|layer:SView |on:Dispatch_AF |result(super):false |type:down
|layer:SViewGroup |on:Touch_BE |type:down
|layer:SViewGroup |on:Touch_AF |result(super):false |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:down
|layer:SActivity |on:Touch_BE |type:down
|layer:SActivity |on:Touch_AF |result(super):false |type:down
|layer:SActivity |on:Dispatch_AF |result(super):false |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move

[move]
...
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SActivity |on:Touch_BE |type:up
|layer:SActivity |on:Touch_AF |result(super):false |type:up
|layer:SActivity |on:Dispatch_AF |result(super):false |type:up
</code></pre>

<ol>
<li>因为系统控件和麻雀控件打印的日志一模一样，所以只贴出一份</li>
<li>这里用<code>BE</code>代表 <code>before</code>，表示该方法开始处理事件的时候，用<code>AF</code>代表<code>after</code>，表示该方法结束处理事件的时候，并且打印处理的结果</li>
<li>从日志中能清楚看到，当<code>View</code>和<code>ViewGroup</code>都不消费<code>DOWN</code>事件时，后续事件将不再传递给<code>View</code>和<code>ViewGroup</code></li>
</ol>

<h4 id="toc_12">场景二</h4>

<p>再配置策略，模拟<code>View</code>和<code>ViewGroup</code>都消费事件，同时<code>ViewGroup</code>在第二个<code>MOVE</code>事件时认为自己需要拦截事件的场景：</p>

<pre><code class="language-kotlin">fun getActivityDispatchDelegate(layer: String = &quot;Activity&quot;): IDispatchDelegate {
    return DispatchDelegate(layer)
}

fun getViewGroupDispatchDelegate(layer: String = &quot;ViewGroup&quot;): IDispatchDelegate {
    return DispatchDelegate(
        layer,
        ALL_SUPER,
        // 表示 onInterceptTouchEvent 方法中，DOWN 事件返回 false，第一个 MOVE 事件返回 false，第二个第三个 MOVE 事件返回 true
        EventsReturnStrategy(T_FALSE, arrayOf(T_FALSE, T_TRUE, T_TRUE), T_SUPER), 
        ALL_TRUE
    )
}

fun getViewDispatchDelegate(layer: String = &quot;View&quot;): IDispatchDelegate {
    return DispatchDelegate(layer, ALL_SUPER, ALL_SUPER, ALL_TRUE)
}
</code></pre>

<p>能看到打印的事件分发跟踪日志：</p>

<pre><code class="language-log">[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept |result(false):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch |result(true):true |type:down
|layer:SView |on:Dispatch_AF |result(super):true |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:down
|layer:SActivity |on:Dispatch_AF |result(super):true |type:down
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(false):false |type:move
|layer:SView |on:Dispatch_BE |type:move
|layer:SView |on:Touch |result(true):true |type:move
|layer:SView |on:Dispatch_AF |result(super):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(true):true |type:move
|layer:SView |on:Dispatch_BE |type:cancel
|layer:SView |on:Touch_BE |type:cancel
|layer:SView |on:Touch_AF |result(super):false |type:cancel
|layer:SView |on:Dispatch_AF |result(super):false |type:cancel
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
 
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Touch |result(true):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
 
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Touch |result(true):true |type:up
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:up
|layer:SActivity |on:Dispatch_AF |result(super):true |type:up
</code></pre>

<ol>
<li>同样因为系统控件和麻雀控件打印的日志一模一样，所以只贴出一份</li>
<li>从日志中能清楚看到，在<code>ViewGroup</code>拦截事件前后，事件是如何分发的</li>
</ol>

<h3 id="toc_13">2.3. 测试结果</h3>

<p>除了以上场景外，我也模拟了其他复杂的场景，能看到系统控件和麻雀控件打印的日志一模一样，这就说明了麻雀控件中的事件分发逻辑，确实与系统源码是一致的。</p>

<p>而且从打印的日志中，能清晰地看到事件分发的轨迹，对理解事件分发过程也有很大的帮助。所以大家如果有需要，也可以直接使用<a href="https://github.com/RubiTree/DispatchTouchEventTutorial/blob/master/app/src/main/java/com/rubitree/dispatchtoucheventtutorial/touchdojo/SystemViews.kt">这个框架像这样</a>对触摸事件分发的各种情况进行调试。</p>

<h2 id="toc_14">3. 实践</h2>

<p>实际上进行事件分发的实践时，会包括两方面内容：</p>

<ol>
<li>一方面是就是控制事件的分发。这也是本文讲的主要内容</li>
<li>另一方面是对事件的处理。核心内容是手势的识别，比如识别用户的操作是单击、双击、长按、滑动，这部分也可以自己手写，不会太难，但一般场景中我们都可以使用SDK提供的十分好用的帮助类<code>GestureDetector</code>，它用起来非常方便</li>
</ol>

<p>时间关系，这部分暂时直接去看另一篇透镜<a href="http://blog.rubitree.com/15467469615604.html">《看穿 &gt; NestedScrolling 机制》</a>吧，它提供了过得去的实践场景。</p>

<h2 id="toc_15">4. 附录</h2>

<p>4.1.事件分发经典伪代码</p>

<pre><code class="language-java">public boolean dispatchTouchEvent(MotionEvent event) {
    boolean consume = false;
    if (onInterceptTouchEvent(event)) {
        consume = onTouchEvent(event);
    } else {
        consume = child.dispatchTouchEvent(event);
    }
    return consume;
}
</code></pre>

]]></content>
  </entry>
  
</feed>
