Android手势分发和嵌套滚动机制

Android手势分发和嵌套滚动机制

前言

在开始介绍下面的嵌套滚动时有必要先打个广告,我们的APP可以在 FineReport & FineBI下载和体验,
后面的嵌套滚动会结合我们APP中的一些使用场景进行讲解

  对于一个Android开发者而言,要开发一个APP你必须要了解事件分发,而要开发一个优秀的APP你就必须要理解嵌套滚动。
  在Android的开发体系里面,手势体系是一块非常重要的内容。从Android诞生之初便有了事件分发,这个分发机制决定了事件的传播流程和事件如何被消费掉。事件传播流程大概呈U字型,是一个先从上到下再从上到下的过程,在从手指按下到手指离开屏幕的一个手势周期中,每个View都有机会消费这个事件。
  但是这套机制也并非完美,如果把手势周期比作一个蛋糕,每个事件是其中的一块块蛋糕,当某个View把传到它面前的那块蛋糕吃掉之后,它就成了后续蛋糕的指定消费者,其他View无法再享用这个蛋糕,哪怕这个消费者已经吃腻了。
  回到我们的APP中,就是当报表消费了滑动手势,则后续的滑动事件都会交给报表,哪怕报表已经无法继续滑动了,外层的表单和下拉刷新组件就接收不到滑动事件了。在越来越追求用户体验的今天,这显然不是一个好事情,Android在兼容开发库(support包)引入了嵌套滚动机制(NestedScroll),甚至在API 23之后的SDK直接内置了这套机制。嵌套滚动机制允许事件消费者把多余的事件主动分享出去。

表单里的报表滑不动了?
报表里的图表滑不动了?
表单还没滑动,下拉刷新怎么先出来了?

  在我们的数据分析APP的开发中,我们遇到过很多看似坑爹的问题,其实这些都是和手势冲突有关的,后面将会分别介绍手势分发和嵌套滚动,以及如何借助嵌套滚动解决这类手势冲突,并且实现更多高大上的交互效果。

手势分发

基础概念:

  • MotionEvent:手势对象,包含有action(事件类型)、坐标等信息。
  • View:安卓的所有视图都是View的子类。为了方便描述,本文用View指代视图单元,是整个视图树的叶子节点,比如TextView、Button等。
  • ViewGroup:视图容器,里面可以包含其他视图,也是View的子类。一般在整个视图树作为非叶节点,比如Scrollview、LinearLayout等。
  • Activity:你就理解为是电影中的一个场景吧,一个安卓APP是由一个或多个Activity组成的。

安卓的手势事件类型包括(部分):

  • ACTION_DOWN:手指按下;

  • ACTION_MOVE:手指移动;

  • ACTION_UP:手指抬起;

  • ACTION_CANCEL:手势终止,比如手势在中途被其他View拦截消费、手势滑出屏幕(非抬起),大部分场景下可视为ACTION_UP;

在多指手势中还有:

  • ACTION_PONINTER_DOWN:其他手指按下;
  • ACTION_POINTER_UP:其他手指抬起;

后面就简单概括为DOWNMOVEUP三类事件。

关键方法

  1. Activity中有两个方法dispatchTouchEventonTouchEvent,整个手势分发从这个dispatchTouchEvent开始,将手势传递到整个View树的根节点,通过深度遍历的方式分发下去,如果没有任何View消费掉的话手势分发将从这个onTouchEvent结束。不过一般都会有个View中途消费掉的。
    伪代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
      //交给view树根节点分发手势
     if (viewRoot.dispatchTouchEvent(ev)) {
          //如果事件被消费了直接返回
          return true;
     }
     //事件没人要了,那就给自己的onTouchEvent吧
     return onTouchEvent(ev);
}
  1. View中恰好也有这两个方法dispatchTouchEventonTouchEvent,其中dispatchTouchEvent如其名是分发手势的,而onTouchEvent是意味事件传到它这了,可以在这里执行一些手势处理的操作。而View默认的dispatchTouchEvent实现非常简单,就是直接交给自己的onTouchEvent,毕竟它是叶子节点,已经处于深度遍历的最后一层。伪代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
      ...
      //直接给自己的onTouchEvent吧
      boolean handled = onTouchEvent(ev);
      ...
      return handled;
}

而onTouchEvent则会利用手势进行一些处理,比如识别单击、长按事件,设置按压状态等.

public boolean onTouchEvent(MotionEvent ev) {
    if(不可点击 && 不可长按 && 不能获取焦点) {
         //要啥自行车,这手势我不要了,给别人吧
         return false;
    }
    //手势类型
    int action = ev.getAction();
      switch(action) {
         case DOWN:
             重置状态();
             启用定时器检查是否长按();
         break;
         case UP:
             if (允许获取焦点?) {
                //所以允许焦点和设置点击事件是一个矛盾体,设置了焦点的View第一次点击不会触发点击事件
                获取焦点();
                break;
             } 
             if (不是长按) {
                 关闭长按检测定时器();
                 触发点击事件();
             }
          break;
      }
      return true;
}
  1. ViewGroup在继承了View的dispatchTouchEventonTouchEvent方法外,还加了onInterceptTouchEventrequestDisallowInterceptTouchEvent方法。
  • onInterceptTouchEvent使得ViewGroup有机会直接拦截手势给自己的onTouchEvent,而不必再向下传播。
  • requestDisallowInterceptTouchEvent是允许下层的某个View阻止其拦截的,一物降一物。

ViewGroup重写了dispatchTouchEvent方法,从这里我们才看到了手势分发的奥秘。
伪代码如下:

public boolean dispatchTouchEvent(MotionEvent ev) {
     int action = ev.getAction();
     if (action == DOWN) {
           //重置消费者
           target = null;
     }
     //1.第一步:先判断一下要不要拦截下来
     boolean intercept = false;
//DOWN事件要考虑考虑;对于非DOWN事件,如果前面DOWN有人认领过也要考虑考虑,没人认领过就是那肯定直接拦截下来
     if (action == DOWN || target != null) {
        if(!disallowInterceptTouchEvent) {
         //询问是否要拦截这个手势
           intercept = onInterceptTouchEvent();
         }
     } else {
         //之前DOWN没一个人要,这孩子多半是没人要了,那后面MOVE也不打算给你了,自己留着
         intercept = true
     }
     
     //2. 第二步:如果不打算拦截,就找当前手势的所在的child分发下去,找DOWN事件的接盘侠.
     //仅针对初始的DOWN事件,后续的MOVE事件是不走这个这一步的
     if (!intercept && action == DOWN) {
        //没拦截,按常规分发
        View child = 手势所在的Child
        if (child != null) {
           //递归分发
           if(child.dispatchTouchEvent(ev)) {
               //这个child接受了这个事件,后续的事件都给它了
               //这里简化了,target其实是个链表
               target = child;
           }
        }
     }
     
     //3. 第三步: 直接指派,包括没有child要消费的DOWN事件及所有的后续事件
     if (target != null) {
         //之前已经有人消费了DOWN,后续的MOVE,UP事件直接给它了(这里有校验target不是第二步刚分发过的view)
         return target.dispatchTouchEvent(ev);
     } else {
         //事件没人要,给自己了,前面知道父类View的dispatch是直接给自己的onTouchEvent
         return super.dispatchTouchEvent(ev);
     }
}

默认的onInterceptTouchEvent方法直接返回false,也就是默认不拦截。容器类视图一般会重写这个方法,比如Scrollview会重新这个方法,在MOVE事件中当y方向上滑动距离达到指定阈值时会拦截手势,并在自己的onTouchEvent方法中执行滑动逻辑。 注意如果没有嵌套滚动的机制,这里就会出现Scrollview里面的报表无法滑动的问题了,因为Scrollview先把事件拦下来了。

图解分发流程

前面的伪代码可能还是很难理解,要结合一些图来看。

  1. 完整的DOWN事件手势流向

    完整的手势流程

    如果事件没有任何打断, 也就是没有任何容器通过onInterceptTouchEvent拦截下来,每个View都没有在onTouchEvent消费掉事件(不设置点击事件之类的),那么一个DOWN事件的走势如上图中的U型,事件从Activity的dispatchTouchEvent开发自上而下一路到最底层View的dispatchTouchEvent,再从最底层View的onTouchEvent一路自下而上到Activity的onTouchEvent。

  2. DOWN事件被某个View的onTouchEvent消费后的MOVE事件流向


    DOWN事件被某个View的onTouchEvent消费后的MOVE事件流向

    红色线条是DOWN事件的走势,蓝色线条是MOVE事件的走势。根据前面伪代码,MOVE事件走的是第三步,基本规则就是谁消费了DOWN事件,就把后续的MOVE给谁了。
    在这里踩过一个坑,在BI-16781中有一个表格无法滑动的原因是单元格设置了手势监听,要检测单击手势并获取单击坐标,根据规则如果要收到UP事件,首先他要拦截DOWN事件,导致上层的RecyclerView接收不到后续事件无法滑动。

  3. DOWN事件被某个dispatchTouchEvent消费后的MOVE事件走向


    DOWN事件被某个dispatchTouchEvent消费后的MOVE事件走向

    由于不调用super方法所以任何onTouchEvent都执行不到了。通过onInterceptTouchEvent拦截并在onTouchEvent消费也是类似的,下层的节点无法接收到任何事件。
    之前的RN添加双击手势监听后原生报表无法滑动就属于后者的情况。PanResponser的onShouldBlockNativeResponder默认属性值为true,表示在DoubleClick组件的原生端通过onInterceptTouchEvent直接拦截下来,并且在onTouchEvent中直接return true消费掉任何事件。

嵌套滚动

那为何要引入嵌套滚动呢?
看我们APP的一个实际效果图,这是符合我们预期的效果

内嵌报表的表单页嵌套滚动效果.gif

这是一个常见的表单内嵌套着报表的情况,上面的布局树结构我们大致可以抽象为:
表单布局树结构.png

我们知道SwipeRefreshLayout(下拉刷新)、NestedScrollView(这里是表单布局)、RecyclerView(表格)都是可滚动的,再复杂点的表格内部还有RecyclerView类型的单元格、支持嵌套滚动的图表单元格。而我们预期要让每个可滚动的组件都有机会滚动,也就是 RecyclerView先滚动,当RecyclerView滚动到顶部的时候Scrollview再继续滚动,当Scrollview也滚动到顶之后SwipeRefreshLayout接着滚动出现下拉刷新。 用一个手势流程图表示:
表单页嵌套滚动

上图中,按照安卓常规的手势分发,显然SwipeRefreshLayout抢先拦截事件(走第一条蓝虚线),它们的判断依据都是滑动距离是否大于阈值。后面的Scrollview和RecyclerView根本没机会滚动。
也就是我们要让MOVE事件按蓝实线走到RecyclerView的onTouchEvent,让RecyclerView成为事实上的事件消费者,同时也要让上面的NestedScrollView和SwipeRefreshLayout有机会滚动,这就需要借助嵌套滚动。

关键接口

  • NestedScrollingChild
    嵌套滚动的发起方,内层的可滚动视图实现该接口,可以将未消费的多余手势滑动距离向上传播给外层可滚动视图。该接口主要有以下关键方法,与后面的NestedScrollingParent接口一一对应:
  • NestedScrollingParent
    嵌套滚动的接收方,外层可滚动视图实现该接口,在接收到内层传来的手势距离后可以根据需要主动滚动自己,并消费掉该距离

当然,一个View可以同时实现上面的两个接口,Parent在无法完全消费掉收到的距离时可以作为Child把剩余的距离继续向上传播。
上图中的SwipeRefreshLayout和NestedScrollView都同时实现了NestedScrollingParent和NestedScrollingChild,而RecyclerView则实现了NestedScrollingChild接口。

关键方法

NestedScrollingChild和NestedScrollingParent接口一组关键方法并且一一对应。

接口 NestedScrollingChild NestedScrollingParent
方法 startNestedScroll onStartNestedScroll/onNestedScrollAccepted
备注 发起嵌套滚动请求,一般在DOWN事件调用,参数中声明嵌套滚动的方向 接收到嵌套滚动请求,如果滚动方向是自己需要的则同意嵌套滚动,这时一般主动放弃拦截MOVE事件
方法 stopNestedScroll onStopNestedScroll
备注 结束嵌套滚动,一般在UP事件调用,无参。 接收到停止嵌套滚动,此时一般会执行停止滚动操作
方法 dispatchNestedPreScroll onNestedPreScroll
备注 在自身滚动前询问外层是否需要滚动,参数声明本次x、y方向滑动距离,并要求接收方告知消费掉的距离和窗口偏移大小 接收到预滚动请求,如果需要可以执行滑动操作,比如下拉显示标题栏功能,这时候可以显示出标题并告诉发起方屏幕向下偏了标题栏高度
方法 dispatchNestedScroll onNestedScroll
备注 在自身滚动之后分发剩余的未消费滑动距离,参数中声明自己已消费x、y距离和未消费的x、y距离,要求接收方告知窗口偏移 接收到滚动请求,此时可以主动滑动来消费掉发起方提供的未消费距离
方法 dispatchNestedPreFling onNestedPreFling
备注 在自身甩动前询问外层是否需要甩动,参数中声明x、y速度 接收到预甩动请求,比较不常用,发起方还没甩动自己先甩起来怪怪的
方法 dispatchNestedFling onNestedFling
备注 在自身甩动之后询问外层是否需要甩动,参数声明x y速度以及是否已消费 接收到甩动请求,一般如果发起方声明未消费甩动则自己可以执行甩动操作

实现原理

为了更好的理解嵌套滚动的原理,下面用一个序列图看的更直观一点。


两层嵌套滚动序列图

上面的序列图就是简单的两层嵌套滚动的场景,对于多层嵌套也是类似的,只不过是Parent在接收到请求时会再向上发起请求。图太大,对一些过程做了简化。


多层嵌套滚动序列图

在嵌套滚动中,最底层的可滚动视图成为事实上的事件消费者,在DOWN事件中就向上宣布我可以滚动,并且我能带你们一起滚动,而上层可滚动视图在收到这个请求后一般都会在后续的MOVE事件中主动放弃拦截。通过NestedScrollingChild和NestedScrollingParent接口的互相配合,完成了先里后外和嵌套滚动,弥补了常规手势分发的至上而下的分发方式带来的不足。
图太长了,结合一点伪代码看看:
这里以RecyclerView (NestedScrollingChild)和NestedScrollView (NestedScrollingParent)为例。
Child在onInterceptTouchEvent阶段会调用嵌套滚动的start和stop方法,可以理解为这是本次嵌套滚动的入口和出口。
Child:

public boolean onInterceptTouchEvent(ev) {
    switch(action) {
        case 'DOWN':
             //作为一个NestedScrollingChild,在DOWN阶段就给Parent打个预防针,表明自己能进行某个方向的嵌套滚动,不会亏待你的,Parent一般接收到符合自己滚动方向的嵌套滚动都会主动放弃拦截
             startNestedScroll(HORIZONTAL|VERTICAL)
             return false
        case 'UP':
            stopNestedScroll()
            return false
        case 'MOVE':
            if(滚动距离大于阈值) {
               进入滚动状态()
               //即将进入滚动状态,我需要后续的事件,没商量余地,所有Parent不得拦截
               requestDisallowInterceptTouchEvent(true)
               return true
            }    
    }
}

而Parent在onInterceptTouchEvent中会判断是否即将处于嵌套滚动中,如果手势所在的Child支持嵌套滚动它是很乐意主动放弃拦截的,因为等下Child会通过嵌套的方式让自己滚动。
Parent:

public boolean onInterceptTouchEvent(ex){
    switch(action) {
        case 'MOVE':
           //这个axes就是前面Child的startNestedScroll传来的滚动方向,由于NestedScrollView是纵向滚动的,如果有一个纵向的嵌套滚动那就大可放心放弃拦截
           if (getNestedScrollAxes() & VERTICAL != 0) {
               return false
           }
           //非嵌套滚动,就走常规路线,正常拦截事件
           if(滚动距离大于阈值) {
               进入滚动状态()
               //即将进入滚动状态,我需要后续的事件,没商量余地,所有Parent不得拦截
               requestDisallowInterceptTouchEvent(true)
               return true
          } 
    }
}

在Child成功拿到MOVE事件并拦截下来后就到了Child的onTouchEvent。

public boolean onTouchEvent(ev) {
    switch(action) {
        case 'DOWN':
           //和onInterceptTouchEvent一样,这里再次start确保进入嵌套滚动(实际上如果前面的start已经锁定了一个Parent的话这次调用会被跳过)
           startNestedScroll(HORIZONTAL|VERTICAL)
           break
        case 'MOVE':
           //1、先触发嵌套预滚动
           if (dispatchNestedPreScroll(dx,dy,scrollConsumed,scrollOffset)) {
               //如果Parent在预滚动阶段消费了部分距离,做一些必要的偏移工作,比如修正dx、dy,修正手势坐标等
           }
           
           //2、自己滚动
           scrollBy(dx,dy)
           ///3、触发嵌套滚动
           if (dispatchNestedScroll(consumedX,consumedY,unconsumedX,unconsumedY,offset) {
           //如果Parent在嵌套滚动阶段消费了部分距离,做一些必要的偏移工作,比如修正dx、dy,修正手势坐标等
           }
        case 'UP':
           if (vx != 0 || vy != 0) {
               //抬起时有加速度,需要执行甩动动作
               //1、触发嵌套预甩动
               dispatchNestedPreFling (vx,vy)
                //2、自己甩动,如果可以的话
               if (canScroll) {
                  fling(vx,vy)
               }
               //3、触发嵌套甩动,告知自己是否已消费
               isConsumed = canScroll
               dispatchNestedFling(vx,vy,isConsumed)
               //结束嵌套滚动
               stopNestedScroll()
           }
    }
}

可见Child在自身scroll和fling前后都给了Parent机会,Parent即使之前主动放弃了拦截MOVE事件它也能有机会去scroll和fling。Parent相对应的响应嵌套滚动的onNestedxxx方法无非就是执行滚动或者继续向上传播嵌套滚动,这里就不列代码了。

嵌套滚动的一些有趣应用场景

嵌套滚动不仅仅能用了解决上面的滚动冲突的问题,还有很多酷炫效果可以通过嵌套滚动来实现。
在谷歌爸爸官方提供的design support包中有很多跟嵌套滚动有关的组件,比如CoordinatorLayout、AppBarLayout,他们的组合能做出很多酷炫的效果。其中CoordinatorLayout一般作为顶级容器,其实现了NestedScrollingParent,站在上帝视角把嵌套滚动借助一个个Behavior实现类分发给其他子节点,比如AppBarLayout借助AppBarLayout.Behavior类可以实现标题栏展开折叠、显示隐藏、标题背景视差滚动等特效;悬浮按钮FloatingActionButton借助FloatingActionButton.Behavior可以实现跟随关联视图的效果。自定义Behavior可以实现你想要的酷炫效果(可以让你的APP吸引更多人气赚更多钱)。
标题栏收起和显示:

标题栏收起和显示.gif

下面这个包含了多个效果,包括标题栏展开折叠、标题栏背景视差滚动、悬浮按钮跟随标题栏移动、悬浮按钮折叠时隐藏等:
标题栏展开折叠.gif

上面的两个例子都是使用网友的一个demo,在 cheesesquare里可以找到。
CoordinatorLayout的种种特效能够运行起来就是依赖嵌套滚动,因此内部要有一个NestedScrollingChild来触发嵌套滚动,上面的例子中的滚动源就是RecyclerView。

下面我自己写了一个简单的demo,展示了标题栏吸附的效果(也就是在状态栏折叠过程中结束滑动会进一步归位到展开或折叠,不会停留在中间状态)、悬浮按钮在显示SnackBar时自动上移(默认效果),以及通过自定义Behavior在NestedScrollView滑动时自动隐藏悬浮按钮,结束滑动后自动显示的效果。

标题栏吸附.gif

查看我的GitHub NestedScrollDemo

总结

  1. 手势分发的DOWN事件流程是按先自上而下再自下而上的U性顺序,中间每个节点都可能被消费掉;非DOWN事件在到达DOWN事件消费者的父节点时直接分发给该消费者,没有消费者则分发给父节点本身。
  2. dispatchTouchEvent负责手势分发,onInterceptTouchEvent负责手势拦截,onTouchEvent负责手势消费,各司其职,尽量不要修改dispatchTouchEvent方法,以免打乱手势分发规则。
  3. 子节点可以通过requestDisallowInterceptTouchEvent和startNestedScroll阻止父节点(或祖先节点)拦截事件。其中requestDisallowInterceptTouchEvent是强制性的,使得父节点的onInterceptTouchEvent方法根本没机会执行;startNestedScroll是发起嵌套滚动,父节点在onInterceptTouchEvent中主动放弃拦截。
  4. 在嵌套滚动中子节点请求父节点不要拦截事件,让事件能够到达子节点并让子节点成为事件消费者,子节点在滚动前后会通知并配合父节点滚动。
  5. 嵌套滚动可以多层嵌套,一个View既可以是NestedScrollingChild也可以是NestedScrollingParent,Child和Parent也不一定是父子关系,也可以是祖孙关系。
  6. API 23以上直接集成了嵌套滚动,任何View都是NestedScrollingChild和NestedScrollingParent。
  7. 嵌套滚动很棒。

推荐阅读更多精彩内容