赞
踩
对于一个Android开发者而言,要开发一个APP你必须要了解事件分发,而要开发一个优秀的APP你就必须要理解嵌套滚动。
在Android的开发体系里面,手势体系是一块非常重要的内容。从Android诞生之初便有了事件分发,这个分发机制决定了事件的传播流程和事件如何被消费掉。事件传播流程大概呈U字型,是一个先从上到下再从上到下的过程,在从手指按下到手指离开屏幕的一个手势周期中,每个View都有机会消费这个事件。
但是这套机制也并非完美,如果把手势周期比作一个蛋糕,每个事件是其中的一块块蛋糕,当某个View把传到它面前的那块蛋糕吃掉之后,它就成了后续蛋糕的指定消费者,其他View无法再享用这个蛋糕,哪怕这个消费者已经吃腻了。
回到我们的APP中,就是当报表消费了滑动手势,则后续的滑动事件都会交给报表,哪怕报表已经无法继续滑动了,外层的表单和下拉刷新组件就接收不到滑动事件了。在越来越追求用户体验的今天,这显然不是一个好事情,Android在兼容开发库(support包)引入了嵌套滚动机制(NestedScroll),甚至在API 23之后的SDK直接内置了这套机制。嵌套滚动机制允许事件消费者把多余的事件主动分享出去。
表单里的报表滑不动了?
报表里的图表滑不动了?
表单还没滑动,下拉刷新怎么先出来了?
在我们的数据分析APP的开发中,我们遇到过很多看似坑爹的问题,其实这些都是和手势冲突有关的,后面将会分别介绍手势分发和嵌套滚动,以及如何借助嵌套滚动解决这类手势冲突,并且实现更多高大上的交互效果。
基础概念:
安卓的手势事件类型包括(部分):
关键方法
1. Activity中有两个方法dispatchTouchEvent和onTouchEvent,整个手势分发从这个dispatchTouchEvent开始,将手势传递到整个View树的根节点,通过深度遍历的方式分发下去,如果没有任何View消费掉的话手势分发将从这个onTouchEvent结束。不过一般都会有个View中途消费掉的。
伪代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) { //交给view树根节点分发手势 if (viewRoot.dispatchTouchEvent(ev)) { //如果事件被消费了直接返回 return true; } //事件没人要了,那就给自己的onTouchEvent吧 return onTouchEvent(ev);}
2. View中恰好也有这两个方法dispatchTouchEvent和onTouchEvent,其中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;}
3. ViewGroup在继承了View的dispatchTouchEvent和onTouchEvent方法外,还加了onInterceptTouchEvent和requestDisallowInterceptTouchEvent方法。
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事件的走势,蓝色线条是MOVE事件的走势。根据前面伪代码,MOVE事件走的是第三步,基本规则就是谁消费了DOWN事件,就把后续的MOVE给谁了。
在这里我一个项目中曾踩过一个坑,有一个表格无法滑动的原因是单元格设置了手势监听,要检测单击手势并获取单击坐标,根据规则如果要收到UP事件,首先他要拦截DOWN事件,导致上层的RecyclerView接收不到后续事件无法滑动。
3. DOWN事件被某个dispatchTouchEvent消费后的MOVE事件走向
由于不调用super方法所以任何onTouchEvent都执行不到了。
通过onInterceptTouchEvent拦截并在onTouchEvent消费也是类似的,下层的节点无法接收到任何事件。
那为何要引入嵌套滚动呢?
看我们APP的一个实际效果图,这是符合我们预期的效果
这是一个常见的表单内嵌套着报表的情况,上面的布局树结构我们大致可以抽象为
我们知道SwipeRefreshLayout(下拉刷新)、NestedScrollView(这里是表单布局)、RecyclerView(表格)都是可滚动的,再复杂点的表格内部还有RecyclerView类型的单元格、支持嵌套滚动的图表单元格。
而我们预期要让每个可滚动的组件都有机会滚动,也就是 RecyclerView先滚动,当RecyclerView滚动到顶部的时候Scrollview再继续滚动,当Scrollview也滚动到顶之后SwipeRefreshLayout接着滚动出现下拉刷新。 用一个手势流程图表示:
上图中,按照安卓常规的手势分发,显然SwipeRefreshLayout抢先拦截事件(走第一条蓝虚线),它们的判断依据都是滑动距离是否大于阈值。后面的Scrollview和RecyclerView根本没机会滚动。
也就是我们要让MOVE事件按蓝实线走到RecyclerView的onTouchEvent,让RecyclerView成为事实上的事件消费者,同时也要让上面的NestedScrollView和SwipeRefreshLayout有机会滚动,这就需要借助嵌套滚动。
当然,一个View可以同时实现上面的两个接口,Parent在无法完全消费掉收到的距离时可以作为Child把剩余的距离继续向上传播。
上图中的SwipeRefreshLayout和NestedScrollView都同时实现了NestedScrollingParent和NestedScrollingChild,而RecyclerView则实现了NestedScrollingChild接口。
NestedScrollingChild和NestedScrollingParent接口一组关键方法并且一一对应。
为了更好的理解嵌套滚动的原理,下面用一个序列图看的更直观一点。
上面的流程图就是简单的两层嵌套滚动的场景,对于多层嵌套也是类似的,只不过是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方法无非就是执行滚动或者继续向上传播嵌套滚动,这里就不列代码了。
当然嵌套滚动的流程并不是都像上面的流程图及伪代码一样一层不变,这里只是参考了NestedScrollView的实现方式。具体实现要和业务场景相结合。
在谷歌爸爸官方提供的design support包中有很多跟嵌套滚动有关的组件,比如CoordinatorLayout、AppBarLayout,他们的组合能做出很多酷炫的效果。其中CoordinatorLayout一般作为顶级容器,其实现了NestedScrollingParent,站在上帝视角把嵌套滚动借助一个个Behavior实现类分发给其他子节点,比如AppBarLayout借助AppBarLayout.Behavior类可以实现标题栏展开折叠、显示隐藏、标题背景视差滚动等特效;悬浮按钮FloatingActionButton借助FloatingActionButton.Behavior可以实现跟随关联视图的效果。自定义Behavior可以实现你想要的酷炫效果(可以让你的APP吸引更多人气赚更多钱)。
标题栏收起和显示:
下面这个包含了多个效果,包括标题栏展开折叠、标题栏背景视差滚动、悬浮按钮跟随标题栏移动、悬浮按钮折叠时隐藏等:
上面的两个例子都是使用网友的一个demo,在cheesesquare里可以找到。
CoordinatorLayout的种种特效能够运行起来就是依赖嵌套滚动,因此内部要有一个NestedScrollingChild来触发嵌套滚动,上面的例子中的滚动源就是RecyclerView。
下面我自己写了一个简单的demo,展示了标题栏吸附的效果(也就是在状态栏折叠过程中结束滑动会进一步归位到展开或折叠,不会停留在中间状态)、悬浮按钮在显示SnackBar时自动上移(默认效果),以及通过自定义Behavior在NestedScrollView滑动时自动隐藏悬浮按钮,结束滑动后自动显示的效果。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。