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:其他手指抬起;
后面就简单概括为DOWN、MOVE、UP三类事件。
关键方法
- Activity中有两个方法dispatchTouchEvent和onTouchEvent,整个手势分发从这个dispatchTouchEvent开始,将手势传递到整个View树的根节点,通过深度遍历的方式分发下去,如果没有任何View消费掉的话手势分发将从这个onTouchEvent结束。不过一般都会有个View中途消费掉的。
伪代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
//交给view树根节点分发手势
if (viewRoot.dispatchTouchEvent(ev)) {
//如果事件被消费了直接返回
return true;
}
//事件没人要了,那就给自己的onTouchEvent吧
return onTouchEvent(ev);
}
- 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;
}
- 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先把事件拦下来了。
图解分发流程
前面的伪代码可能还是很难理解,要结合一些图来看。
-
完整的DOWN事件手势流向
如果事件没有任何打断, 也就是没有任何容器通过onInterceptTouchEvent拦截下来,每个View都没有在onTouchEvent消费掉事件(不设置点击事件之类的),那么一个DOWN事件的走势如上图中的U型,事件从Activity的dispatchTouchEvent开发自上而下一路到最底层View的dispatchTouchEvent,再从最底层View的onTouchEvent一路自下而上到Activity的onTouchEvent。 -
DOWN事件被某个View的onTouchEvent消费后的MOVE事件流向
红色线条是DOWN事件的走势,蓝色线条是MOVE事件的走势。根据前面伪代码,MOVE事件走的是第三步,基本规则就是谁消费了DOWN事件,就把后续的MOVE给谁了。
在这里踩过一个坑,在BI-16781中有一个表格无法滑动的原因是单元格设置了手势监听,要检测单击手势并获取单击坐标,根据规则如果要收到UP事件,首先他要拦截DOWN事件,导致上层的RecyclerView接收不到后续事件无法滑动。 -
DOWN事件被某个dispatchTouchEvent消费后的MOVE事件走向
由于不调用super方法所以任何onTouchEvent都执行不到了。通过onInterceptTouchEvent拦截并在onTouchEvent消费也是类似的,下层的节点无法接收到任何事件。
之前的RN添加双击手势监听后原生报表无法滑动就属于后者的情况。PanResponser的onShouldBlockNativeResponder默认属性值为true,表示在DoubleClick组件的原生端通过onInterceptTouchEvent直接拦截下来,并且在onTouchEvent中直接return true消费掉任何事件。
嵌套滚动
那为何要引入嵌套滚动呢?
看我们APP的一个实际效果图,这是符合我们预期的效果
这是一个常见的表单内嵌套着报表的情况,上面的布局树结构我们大致可以抽象为:
我们知道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吸引更多人气赚更多钱)。
标题栏收起和显示:
下面这个包含了多个效果,包括标题栏展开折叠、标题栏背景视差滚动、悬浮按钮跟随标题栏移动、悬浮按钮折叠时隐藏等:
上面的两个例子都是使用网友的一个demo,在 cheesesquare里可以找到。
CoordinatorLayout的种种特效能够运行起来就是依赖嵌套滚动,因此内部要有一个NestedScrollingChild来触发嵌套滚动,上面的例子中的滚动源就是RecyclerView。
下面我自己写了一个简单的demo,展示了标题栏吸附的效果(也就是在状态栏折叠过程中结束滑动会进一步归位到展开或折叠,不会停留在中间状态)、悬浮按钮在显示SnackBar时自动上移(默认效果),以及通过自定义Behavior在NestedScrollView滑动时自动隐藏悬浮按钮,结束滑动后自动显示的效果。
查看我的GitHub NestedScrollDemo
总结
- 手势分发的DOWN事件流程是按先自上而下再自下而上的U性顺序,中间每个节点都可能被消费掉;非DOWN事件在到达DOWN事件消费者的父节点时直接分发给该消费者,没有消费者则分发给父节点本身。
- dispatchTouchEvent负责手势分发,onInterceptTouchEvent负责手势拦截,onTouchEvent负责手势消费,各司其职,尽量不要修改dispatchTouchEvent方法,以免打乱手势分发规则。
- 子节点可以通过requestDisallowInterceptTouchEvent和startNestedScroll阻止父节点(或祖先节点)拦截事件。其中requestDisallowInterceptTouchEvent是强制性的,使得父节点的onInterceptTouchEvent方法根本没机会执行;startNestedScroll是发起嵌套滚动,父节点在onInterceptTouchEvent中主动放弃拦截。
- 在嵌套滚动中子节点请求父节点不要拦截事件,让事件能够到达子节点并让子节点成为事件消费者,子节点在滚动前后会通知并配合父节点滚动。
- 嵌套滚动可以多层嵌套,一个View既可以是NestedScrollingChild也可以是NestedScrollingParent,Child和Parent也不一定是父子关系,也可以是祖孙关系。
- API 23以上直接集成了嵌套滚动,任何View都是NestedScrollingChild和NestedScrollingParent。
- 嵌套滚动很棒。