[Android]触摸、滑动与嵌套滑动(一)事件与滚动

1,147 阅读14分钟

触摸事件与滑动,一直是Android知识库中的一个不可避免被触及的一个大的方向。

由此,几个比较核心的话题需要我们知道:

  1. 触摸事件的传递模型;

  2. 如何通过手指去滑动视图;

  3. 如果有多个可滑动视图的情况下,如何合理地安排滑动的先后顺序;

  4. 如何做到嵌套滑动

其中的一和二都是老生常谈的话题,无非就是事件冒泡模型和滑动偏移的计算,3和4则会在一些业务中才会具体地被使用到,例如3,如果ViewPager嵌套RecylerView,那么此时的事件应该交给谁以避免滑动冲突带来的不友好的用户体验。再比如4,如何在两个嵌套的可滑动View中,得到友好的滑动体验,换句话说,ScrollerView和ScrollerView嵌套和NestedScrollerView、NestedScrollerView嵌套又有哪些区别?

我们从一个最常见的问题开始说起,如果两个ScrollerView套在一起会发生什么?很简单,套在里面的ScrollerView直接没法滑动了,即使它的内容展示不全:

那么我们要怎么解决这个问题呢?

网上很容易收到,把ScrollerView换成NestedScrollerView就可以了,所以,我们再次尝试,将外层的ScrollerView换成NestedScrollerView,你会发现,还是不行。再试,保持外面的ScrollerView不变,将里面的换成NestedScrollerView,你会发现,成功了,我们实现了我们的需求:

背后的原因,正是我们今天所要探究的话题:Android触摸与事件的传递

1. 事件冒泡模型

我们知道,Android设备大多数是通过触摸屏来进行外界事件输入的,当我们输入的时候,触摸屏会进行高密度的事件采样,将手指触摸在屏幕上的某一个位置信息传递给我们的操作系统,再由操作系统派发给具体的某一个Window,对应的Activity,最后通过View Hierarchy,到达我们的View上,我们就可以在View的代码中处理这一个触摸事件。

实际上我们手指点按下去的时候,高密度的采样,会触发一系列的触摸事件:按下、很多个滑动采样和抬起,所以点按屏幕的时候,其中的第一个采样事件会经过如下的步骤,从中间的「点击」开始:

首先有两个部分,一是下方的硬件层和系统代码层,这一块主要是屏幕感知触控,产生相应的事件。其次是软件层,也就是App层,Window会负责接收从系统层面发出的MotionEvent事件,也就是我们的点击事件。而事件在系统层面的传递是向下的,先由屏幕传递给驱动,再由驱动传递给系统,系统内部处理完后吗,再向上派发给App。

而App层面的传递是向上的,从最底层的Window开始, 传递给DecorView,再是ViewGroup,最终找到目标View。

向上向下是如何规定的?

你可以想象一下,现在最上方有一双眼睛在看着屏幕,它首先看到的一定是最顶部的View,再是ViewGroup,因为默认情况下,View Hierarchy是堆叠而成的。

虽然我们点击了屏幕,动作是向下的,最底层处理完触摸信号之后,最终点击事件却是往上浮的,就想往上冒泡一样,这便是我们的事件冒泡模型。

2. View中和事件上浮的相关方法

我们知道,事件是不断向上冒泡的,但是事件不断上浮的过程中,经过的所有View、ViewGroup或者其它的节点,都会面对一件事情:要不要消费该事件和要不要向下传递

2.1 事件的拦截方法

例如上述的Activity、DecorView、ViewGroup和View2构成的一个View Hierarchy,它们四个都有权去消费一个事件,也有权利去决定一个事件是否要继续下发。如果你写了一个不讲武德的ViewGroup,重写它的方法:onInterceptTouchEvent,里面返回了true,那事件在ViewGroup这一层就被拦截了,不会再上浮。

假设此时事件已经上浮到View了,因为View并没有子View,他已经在View Hierarchy构成的树中是一个 叶子节点了,他就不需要再去考虑是否需要拦截事件,所以View是没有 onInterceptTouchEvent的。

此外,默认情况下只会在dispatchTouchEvent中接收ACTION_DOWN事件的时候调用onInterceptTOuchEvent。

2.2 事件的派发方法

onInterceptTouchEvent算是一个比较独立的方法,它只决定事件是否被ViewGroup拦截。而事件上浮的过程中,最最主要的方法有两个,onTouchEventdispatchTouchEvent。前者是怎么去消费这个事件,后者是决定是否派发这个事件。

你可能会觉得奇怪,既然View没有子View,为什么要决定怎么去派发事件。

默认情况下,ViewGroup的dispatchTouchEvent被ViewGroup重写了,他会去遍历它所有的子View,根据位置查看是否是当前子View是否在触摸点上,如果ViewGroup有多个,这个过程会被重复多次。

而View的dispatchTouchEvent则是去调用onTouchEvent,该方法的作用就是去消费事件,如果确定要消费返回true,否则返回false。

无论是View还是ViewGroup,dispatchTouchEvent的返回值的意义都是该事件是否被本View(ViewGroup)消费,View好理解,因为它在最顶端,不会再下发,它的返回值其实就是onTouchEvent的返回值。

ViewGroup要考虑向上派发,所以它会先向上派发事件,如果该事件被子View消费了,子View的dispatchTouchEvent会返回true,此时该ViewGroup的dispatchTouchEvent将不会再去消费事件了,返回true,将一路向下传递,返回给操作系统;如果子View的dispatchTouchEvent返回了false,此时ViewGroup就会调用自己的onTouchEvent去尝试消费事件,于是乎ViewGroup在此刻成为了可能消费事件的“子View”。

所以对于1中图片描述的的事件冒泡模型只有一半,如果事件没有被子View消费,事件冒泡到顶端之后,事件会再次下沉,反着问View Hierarchy上的各个ViewGroup是否要消费事件,如绿色箭头;如果被子View消费了,事件同样会下沉,只不过余下的ViewGroup是没有机会再去消费这个事件了。

所以,但从事件传递上来看,View其实可以不要dispatchTouchEvent的,但是有一些其它的逻辑,比如NestedScroll,即嵌套滑动这类的方法严格来说不是自己消费事件,会在dispatchTouchEvent中做一些处理,将额外的数据派发给其他的事件,更多的意义还是将对外消费事件对自己消费事件区分开来。

此外,只要事件经过了ViewGroup,ViewGroup就能看见这个事件,只不过ViewGroup会根据子View的dispatchTouchEvent的返回值来决定是否去调用自己的onTouchEvent()。

2.3 事件的消费方法

就是在onTouchEvent去处理一个接收的到的事件。

如果是叶子节点,通常是嵌套在ViewGroup中的一个View,它要接收事件,就会接受第一个手指按下的事件,此后的手指移动的事件会下发它来处理,也就是调用它的onTouchEvent来让View决定如何处理这个事件。

如果在View的onTouchEvent对手指的「按下事件」返回了false,则说明View将不会处理这一系列的事件,包括后续的手指移动(MOVE)、手指抬起(UP)等等,都将不会继续下发给它。

如果在View的onTouchEvent对手指的「滑动事件」返回了false,则说明View不处理这次的滑动事件,按照冒泡机制,这个滑动事件本应该重新回到ViewGroup的dispatchTouchEvnet,但是此时的ViewGroup也不会再去消费和这个事件了。

下一个滑动事件仍然会派发给View,并不会因为MOVE事件没有被消费而跳过该View。

换句话说,除非ViewGroup主动拦截,否则一系列的事件,被View消费了一半,突然View不消费了(onTouchEvent 返回false),ViewGroup也不会再去消费了。

3. 事件的分类

目前的事件机制,指的是不重写上述的 onInterceptTouchEvent dispatchTouchEvent 方法的情况下,由 View.class和ViewGroup.class 的默认的机制去处理事件。

3.1 主要事件

主要事件的分类其实主要就四种:

  1. ACTION_DOWN
  2. ACTION_MOVE
  3. ACTION_UP
  4. ACTION_CANCEL

分别对应手指的事件:按下、滑动和抬起,最后一个CANCEL则对应着事件的取消,在基础的冒泡模型中,我们先关注前三个。

理所应当地,事件在上浮的过程中,目前的事件机制会保证对应位置上的所有的View都能收到ACTION_DOWN事件。

为什么是ACTION_DOWN事件呢?

因为ACTION_DOWN事件是一次触摸行为的起点,一次触摸必然是以按下为起点,中间链接了多个移动,最后手指抬起,这里就催生出一个结论:

  • 如果一个控件不接受ACTION_DOWN事件,那后续就不会得到ACTION_MOVE和ACTION_UP。

如果两个同属于ViewGroup的View叠在一起呢?

一样能收到,ViewGroup会优先遍历视图的上层View,因为如果上层View不消费事件,在ViewGroup遍历子View时,会接着遍历下一层View。

详见:再谈触摸、滑动与嵌套滑动(二)第3部分

3.2 ACTION_CANCEL

ACTION_CANCEL并不属于主要事件,因为它直译为取消。如果是手指在屏幕上滑动,无论如何也不可能突然导致事件的取消,因为手指的动作无非就是按下、滑动和抬起,并没有取消。所以,取消的场景自然应该存在于冒泡模型之上,换句话说它不是由用户手指触发的,而应该是由一些特殊的UI逻辑触发的。

那ACTION_CANCEL究竟是何时触发的?答案其实就在名字上,正是取消

何谓取消?

上文提到了,所有的View都会受到ACTION_DOWN,即能够感知手指按下的动作。如果此时有这么个场景:ScrollView里面有一个Button,我们手指按在Button上,但是没有立即抬起,而是向上滑动,此时的事件会怎么样呢?

首先Button必然会收到一个ACTION_DOWN,它宣布,它会消费这个ACTION_DOWN,作为一个Button,它肯定在等待ACTION_UP,以构成一个Click事件。

在此之后,ScrollView仍然能收到这一串事件的后续事件,因为事件的冒泡一定会再经过ScrollView传递到Button。

如果你的手指立即抬起,此时的Button就认为自己被点击了,于是调用performClick,触发点击监听方法;

但是如果你手指没有立即抬起,而是向上滑了一小段距离(距离 > TouchSlope)。

TouchSlope是系统所能识别出的可以被认为是滑动的最小距离,小于TouchSlope的滑动不认为是滑动。

此时事件经过ScrollView的时候,ScrollView发现已经上下滑动,并且超过了滑动阈值了,不能不管了,为了ScrollView中的内容可以正常上下滚动,于是乎便拦截该ACTION_MOVE事件,此时便会派发给接收ACTION_DOWN的Button一个ACTION_CANCEL。同时响应下一个ACTION_MOVE。这里就衍生出另两个结论:

  1. 即使一个控件消费了ACTION_DOWN事件,也不意味着它就能收到本次触摸后续的所有事件。
  2. 即使一个控件没有消费ACTION_DOWN事件,也可能会通过拦截事件的方式消费后续的ACTION_MOVE事件。
  1. 这是很好观察的,因为Android 的Button自带水波纹效果,该事件被CANCEL之前水波纹一直都在;一旦收到CANCEL事件,水波纹就消失了,同时(收到下一个ACTION_MOVE之后)ScrollView开始滑动。
  2. 一次Click是由ACTION_DOWN和ACTION_UP构成的。
  3. 对于Button来说,收到ACTION_DOWN之后,收到几个ACTION_MOVE,再收到ACTION_CANCEL就没有后续了,这就是取消的含义;
  4. 对于ScrollView来说,在Button收到ACTION_CANCEL之后,他会直接收到滑动的ACTION_MOVE,直到手指抬起,再收到ACTION_DOWN,他并不会重新收到ACTION_DOWN。

上述现象的具体的流程是:

  1. 当外层的ScrollView探测到纵向的滑动符合自己的滑动条件,于是将mIsBeingDragged置为true,并在onInterceptTouchEvent中返回mIsBeingDragged,也就是true。
  2. 然后ScrollView会将原有的ACTION_MOVE替换成ACTION_CANCEL,到Button上。
  3. 此后ScrollView便开始接管ACTION_MOVE事件:
2023-01-30 14:12:54.854 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), dispatchTouchEvent:ACTION_MOVE
2023-01-30 14:12:54.854 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), onInterceptTouchEvent:ACTION_MOVE
/**此时ScrollView在onInterceptorTouchEvent中返回了true,拦截事件,并且把MotionEvent的Action换成了ACTION_CANCEL**/
2023-01-30 14:12:54.854 18990-18990/com.example.actions D/rEd: CustomButton(267738707), dispatchTouchEvent:ACTION_CANCEL
2023-01-30 14:12:54.854 18990-18990/com.example.actions D/rEd: CustomButton(267738707), onTouchEvent:ACTION_CANCEL,consumed:true
/**这里的MotionEvent和上面ACTION_MOVE的Event其实是一样的,不信你可以打印一下它们的,只不过类型被ScrollView替换掉了。**/
2023-01-30 14:12:54.905 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), dispatchTouchEvent:ACTION_MOVE
2023-01-30 14:12:54.905 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), onTouchEvent:ACTION_MOVE,consumed:true
2023-01-30 14:12:54.920 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), dispatchTouchEvent:ACTION_MOVE
2023-01-30 14:12:54.920 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), onTouchEvent:ACTION_MOVE,consumed:true
2023-01-30 14:12:54.940 18990-18990/com.example.actions D/rEd: CustomScrollView(165088834,), dispatchTouchEvent:ACTION_MOVE

ScrollView的ACTION_MOVE和Button的ACTION_CANCEL,对应的MotionEvent实际上是同一个对象,只不过ScrollView拦截事件冒泡的时候,会将ACTION_MOVE换成ACTION_CANCEL,继续下发:

此外,View突然不可见(isVisible = false)和手指滑动到View之外都不会触发ACTION_CANCEL事件,但是ScrollView下方的容器LinearLayout突然调用了removeView,导致对应的Button视图被删除的话也会触发ACTION_CANCEL事件。

4. View是怎样滑动的

我们知道,在一次滑动的过程中,会有一个ACTION_DOWN,无数个ACTION_MOVE(具体的数量取决于采样次数)和一个ACTION_UP,滑动的本质,无非就是在监控手指的移动位置,所以我们视图滚动的核心,就是根据滑动事件来设置View内容的偏移量

一般来说,我们在onTouchEvent接收到ACTION_DOWN的时候去记录下初始数据,mLastX和mLastY用于标记上一次事件的滑动坐标点:

mLastX = event.x;
mLastY = evnet.y;

然后再ACTION_DOWN中,计算新的event的x和y与Last的差值:

val deltaX = mLastX - event.x;
val deltaY = mLastY - event.y;
mLastX = event.x;
mLastY = event.y;

这样一来,newX和newY便是滑动的偏移量,也就是接收到本ACTION_MOVE之后的视图内容偏移量。

最终我们需要让View动起来:

scrollBy(deltaX,deltaY);

所以,滑动的本质是:多个事件中手指位置的差值驱动View通过scrollBy方法做内容的偏移

5. 总结

我们介绍了滑动的事件冒泡模型、事件派发的方法和细节以及事件的分类。

事件的派发主要遵循几个规律:

  1. ACTION_DOWN事件优先派发给叶子节点的View,只有子View不需要的情况下,父ViewGroup才有机会去消费这个ACTION_DOWN。

  2. 如果一个控件不接受ACTION_DOWN事件,那后续就不会得到ACTION_MOVE和ACTION_UP,所以父ViewGroup一般不会拦截ACTION_DOWN事件,否则本次触控行为下的其他的ACTION_MOVE和ACTION_UP都将无法流到子View上。

  3. 即使一个控件消费了ACTION_DOWN事件,也不意味着它就能收到本次触摸后续的所有事件。 ACTION_MOVE事件在父View滑动和子View点击发生冲突的时候,可能会被父View拦截,并向子View派发ACTION_CANCEL事件,此后的ACTION_MOVE事件由父View来消费。

  4. 即使一个控件没有消费ACTION_DOWN事件,也可能会通过拦截事件的方式消费后续的ACTION_MOVE事件。

这些内容都是比较重要的,也是理解滑动冲突和嵌套滑动的基础。

~End