美文网首页Appview事件分发Android自定义View
Android手势分发和嵌套滚动机制

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

作者: 黄名堡 | 来源:发表于2018-03-26 18:40 被阅读1019次

    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. 嵌套滚动很棒。

    相关文章

      网友评论

      本文标题:Android手势分发和嵌套滚动机制

      本文链接:https://www.haomeiwen.com/subject/zrplcftx.html