在 Android 开发中,用户与应用的交互主要通过触摸事件来实现。为了处理这些事件,Android 提供了一套完整的事件分发机制。理解这一机制对于开发高效、响应迅速的应用至关重要。本文将探讨 Android 的事件分发机制,开篇之前先明确一些概念,抽象事件行为的MotionEvent,处理链路上的分发、拦截和消费,它们是实现事件处理的基本逻辑单元。
本文目录(可以复制感兴趣的目录名称,快速页面搜索跳转到对应章节)
#一、基本概念
### 1. MotionEvent
### 2. 事件的产生和消费
#二、事件处理
### 1. 分发
### 2. 拦截
### 3.消费
#三、事件分发角色
###1. View
###2. ViewGroup
###3. Activity
#四、事件分发状态
### 1. ACTION_DOWN
### 2. ACTION_MOVE
### 3. ACTION_UP
#五、事件分发具体流程
### 1. dispatchTouchEvent
### 2. onInterceptTouchEvent
### 3. onTouchEvent
### 4.消费起点 ACTION_DOWN
### 5. 总结
#六、滑动冲突处理
### 1.内部拦截法
### 2.外部拦截法
### 3.案例
##### 1)内部拦截法解决
##### 2)外部拦截法解决
##### 3)总结
#七、小tips
### 1. 手指按下后滑动离开视图,为什么不响应点击状态?
### 2. ACTION_CANCEL 为什么会存在
### 3. setOnClickListener
### 4. ViewGroup.mFirstTouchTarget(TouchTarget)
### 5. 事件机制的复用池
### 6. 事件分发为什么由内而外(由于父到子)
一、基本概念
1. MotionEvent
MotionEvent 是 Android 中用于处理触摸事件的类。它封装了与触摸屏幕相关的所有信息,包括触摸的坐标、触摸的类型、触摸的状态等
主要触摸事件类型
ACTION_DOWN: 用户按下触摸屏幕的事件。
ACTION_UP: 用户抬起手指的事件。
ACTION_MOVE: 用户在屏幕上移动手指的事件。
ACTION_CANCEL: 事件被取消,例如由于系统的其他操作。
获取触摸点的坐标
getX(int pointerIndex): 获取指定手指的 X 坐标(相对父视图)。
getY(int pointerIndex): 获取指定手指的 Y 坐标(相对父视图)。
getRawX(): 获取触摸事件的原始 X 坐标(相对于屏幕)。
getRawY(): 获取触摸事件的原始 Y 坐标(相对于屏幕)。
2. 事件的产生和消费
Android系统是由事件驱动的,而input是最常见的事件之一,用户的点击、长按、滑动等都属于input事件驱动。
事件生产
InputManagerService负责监听到这些屏幕事件,然后依据窗口是否聚焦、位置是否包含点击点等条件分发给可以响应事件的应用窗口,然后WMS将事件抛给我们App的ViewRootImpl。对于我们App来说,这是一个事件的生产过程。App将事件抽象成MotionEvent,然后依据一定的规则进行内部分发处理。
事件消费
ViewRootImpl获得事件后,先将事件派发给DecorView,DecorView派发给Activity,Activity再派发给PhoneWindow,PhoneWindow再派发给DecorView,最终DecorView依据视图层级和位置关系,将事件派发到可以具体消费事件的视图。这里有点绕,为啥DecorView不直接进行View树的传递,而要先经过Activity、PhoneWindow等,原因是留一个口子给开发者,便于开发者对事件源头的统一管理,我们可以设置Window的某些统一属性或者重写Activity的事件方法来对事件源头做统一、全局的管理
具体来看,生产+消费过程应该是这样的
二、事件处理
事件的处理包含分发、拦截和消费,这几个阶段需要分开看,这个过程类似双亲委派双向关系,分发的主要流向从父视图到子视图(可以拦截),消费过程则相反,从子视图到父视图。
1. 分发
事件在View树的父->子传递过程,开放一般经常打交道的主要就是Activity和View,Activity我们用作全局管理,而View树的分发过程则是我们需要着重理解的重点。分发过程函数是
public boolean dispatchTouchEvent(MotionEvent event)
这个函数有一个返回值,表示该事件是否被子视图处理(消费),Android 中的视图(View、ViewGroup)接收到的触摸事件都是通过这个方法来进行分发的,如果事件能够传递给当前视图,则此方法一定会被调用。如果当前视图类型是 ViewGroup,该方法内部会调用 。onInterceptTouchEvent(MotionEvent)方法来判断是否拦截该事件,如果onInterceptTouchEvent拦截了,事件将不再传递给子视图。
2. 拦截
这其实是分发过一个环节,用来控制事件是否继续传递下去,如果下列方法返回false,事件将结束传递,子视图将无法再感知到事件。
public boolean onInterceptTouchEvent(MotionEvent ev)
该方法的目的是为了向子视图传递事件,因此View不具有该方法,仅ViewGroup有。
3.消费
事件消费是指事件被某个视图接收并完成相关处理操作,事件被消费后,将会终止传递。
public boolean onTouchEvent(MotionEvent event)
具体的在平时开发中,如果对一个View设置了OnClickListener,那这个View被标记为clickable,onTouchEvent
方法检测到该标记后会返回clickable(true),标记事件被消费。当然系统源码中处理事件是否消费还有许多其他逻辑,这里我们只探讨主线逻辑
一次完整的事件处理流程
- 根ViewGroup收到消息后,调用
dispatchTouchEvent
方法进行分发 - 在
dispatchTouchEvent
中调用onInterceptTouchEvent
判断是否被拦截,如果拦截了则调用onTouchEvent
判断是否被消费 - 如果没有拦截,则继续向子视图调用
dispatchTouchEvent
进行分发
伪代码表示如下
public boolean dispatchTouchEvent(MotionEvent event) {
// 先判断是否拦截事件
if (onInterceptTouchEvent(event)) {
// 如果拦截,直接处理事件
return onTouchEvent(event);
} else {
// 否则,分发给子视图
for (View child : children) {
if (child.dispatchTouchEvent(event)) {
return true; // 如果子视图处理了事件,返回 true
}
}
}
return false; // 没有处理事件
}
综上所述,分发是指事件从父到子的过程(内->外),这个阶段主要是决定哪些视图能获得这个事件,这个过程中有机会拦截分发,拦截了子视图就无法获得事件;消费过程是指从子到父的过程(外->内),也就是我们平时响应事件的过程,如果子视图消费了这个这个事件,父视图就无法再次消费。整个流程可以简单的视为分发+消费过程
三、事件分发角色
1. View
View没有子视图,因此事件到View就终止了,所以View没有拦截事件的方法,View只有下列两个方法
public boolean dispatchTouchEvent(MotionEvent event)
public boolean onTouchEvent(MotionEvent event)
2. ViewGroup
ViewGroup是容器视图,可以包含子视图,因此拦截方法中多了拦截方法,可以视策略决定是否将事件传递给子视图
public boolean dispatchTouchEvent(MotionEvent event)
public boolean onInterceptTouchEvent(MotionEvent event) // 多了拦截
public boolean onTouchEvent(MotionEvent event)
3. Activity
Activity本质并不是视图,前文说到设计Activity之所以能够参与事件序列,是为了提供给开发一个处理事件源头的地方,包含分发和消费方法
public boolean dispatchTouchEvent(MotionEvent event)
public boolean onTouchEvent(MotionEvent event)
这里会有点奇怪,为什么不提供onInterceptTouchEvent
方法,主要是考虑到指责清晰,Activity并非一个真正的视图,主要作用是管理生命周期,处理事件源头,ViewGroup根据视图树提供了拦截机制,本质上Activity并不处于视图树中,如果强行添加拦截方法会导致Activity、ViewGroup都有拦截逻辑,这样增加维护成本和性能开销,而且如果两套逻辑有差异,将会带来更多问题
四、事件分发状态
一次完整的事件过程应该是这样的“按下+抬起”,对于到事件状态是“ACTION_DOWN + ACTION_UP”,这只是理论上的状态,实际上用户手指在按下和他起过程,不可避免的会发生滑动即ACTION_MOVE,所以安卓中,一次完整的事件处理过程通常是 ACTION_DOWN -> ACTION_MOVE(如果有) -> ACTION_UP
1. ACTION_DOWN
用户手指的按下操作,是用户每次触摸屏幕时触发的第一个事件,ACTION_DOWN作为事件分发起点,决定着一次事件该由哪个视图消费,后续事件序列如何分发,一次事件中有且仅有一次
2. ACTION_MOVE
用户手指按压屏幕后,在松开手指之前如果滑动屏幕超出一定的阈值,则发生了 ACTION_MOVE 事件,这个阈值通常是系统配置,一次事件中大于等于0次
ViewConfiguration.get(context).getScaledTouchSlop();
3. ACTION_UP
用户手指离开屏幕时触发的操作,是当次触摸操作的最后一个事件,即消费终点,一次事件中有且仅有一次,它决定本次事件是否成功消费
五、事件分发具体流程
先看下Demo,Activity叠了三层子视图,运行效果截图如下
xml层级结构如下
<LinearLayout1>
<LinearLayout2>
<MyTextView>
</MyTextView>
</LinearLayout2>
</LinearLayout1>
视图源码
------------------------MainActivity.kt------------------------
class MainActivity : ComponentActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "MainActivity: dispatchTouchEvent>>>> ${ev?.type()}")
val dispatched = super.dispatchTouchEvent(ev)
Log.i("nius", "MainActivity: dispatchTouchEvent<<<< ${ev?.type()} dispatched:$dispatched")
return dispatched
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
Log.i("nius", "MainActivity: onTouchEvent>>>> ${event?.type()}")
val resumed = super.onTouchEvent(event)
Log.i("nius", "MainActivity: onTouchEvent<<<< ${event?.type()}, resumed:$resumed")
return resumed
}
}
------------------------Utils.kt------------------------
fun MotionEvent?.type(): String {
return when (this?.actionMasked) {
MotionEvent.ACTION_DOWN -> "ACTION_DOWN"
MotionEvent.ACTION_MOVE -> "ACTION_MOVE"
MotionEvent.ACTION_UP -> "ACTION_UP"
MotionEvent.ACTION_CANCEL -> "ACTION_CANCEL"
else -> "other:${this?.actionMasked}"
}
}
------------------------ViewLayers.kt------------------------
class LinearLayout1 @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attr, defStyleAttr) {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout1: dispatchTouchEvent>>>> ${ev?.type()}")
val dispatched = super.dispatchTouchEvent(ev)
Log.i("nius", "LinearLayout1: dispatchTouchEvent<<<< ${ev?.type()}, dispatched:$dispatched")
return dispatched
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout1: onInterceptTouchEvent>>>> ${ev?.type()}")
val intercept = super.onInterceptTouchEvent(ev)
Log.i("nius", "LinearLayout1: onInterceptTouchEvent<<<< ${ev?.type()}, intercept:$intercept")
return intercept
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout1: onTouchEvent>>>> ${event?.type()}")
val resumed = super.onTouchEvent(event)
Log.i("nius", "LinearLayout1: onTouchEvent<<<< ${event?.type()}, resumed:$resumed")
return resumed
}
}
class LinearLayout2 @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attr, defStyleAttr) {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout2: dispatchTouchEvent>>>> ${ev?.type()}")
val dispatched = super.dispatchTouchEvent(ev)
Log.i("nius", "LinearLayout2: dispatchTouchEvent<<<< ${ev?.type()}, dispatched:$dispatched")
return dispatched
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout2: onInterceptTouchEvent>>>> ${ev?.type()}")
val intercept = super.onInterceptTouchEvent(ev)
Log.i("nius", "LinearLayout2: onInterceptTouchEvent<<<< ${ev?.type()}, intercept:$intercept")
return intercept
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
Log.i("nius", "LinearLayout2: onTouchEvent>>>> ${event?.type()}")
val resumed = super.onTouchEvent(event)
Log.i("nius", "LinearLayout2: onTouchEvent<<<< ${event?.type()}, resumed:$resumed")
return resumed
}
}
class MyTextView @JvmOverloads constructor(
context: Context,
attr: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextView(context, attr, defStyleAttr) {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
Log.i("nius", "MyTextView: dispatchTouchEvent>>>> ${ev?.type()}")
val dispatched = super.dispatchTouchEvent(ev)
Log.i("nius", "MyTextView: dispatchTouchEvent<<<< ${ev?.type()}, dispatched:$dispatched")
return dispatched
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
Log.i("nius", "MyTextView: onTouchEvent>>>> ${event?.type()}")
val resumed = super.onTouchEvent(event)
Log.i("nius", "MyTextView: onTouchEvent<<<< ${event?.type()}, resumed:$resumed")
return resumed
}
}
当前我们仅仅复写了事件分发方法,没有任何操作,先直观感受一下分发状态
点击MyTextView
区域,过滤关键字>>>>
ACTION_DOWN 分发路径
MainActivity: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: onInterceptTouchEvent>>>> ACTION_DOWN
LinearLayout2: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout2: onInterceptTouchEvent>>>> ACTION_DOWN
MyTextView: dispatchTouchEvent>>>> ACTION_DOWN
MyTextView: onTouchEvent>>>> ACTION_DOWN
LinearLayout2: onTouchEvent>>>> ACTION_DOWN
LinearLayout1: onTouchEvent>>>> ACTION_DOWN
MainActivity: onTouchEvent>>>> ACTION_DOWN
ACTION_MOVE 分发路径
MainActivity: dispatchTouchEvent>>>> ACTION_MOVE
MainActivity: onTouchEvent>>>> ACTION_MOVE
MainActivity: dispatchTouchEvent>>>> ACTION_MOVE
MainActivity: onTouchEvent>>>> ACTION_MOVE
MainActivity: dispatchTouchEvent>>>> ACTION_MOVE
MainActivity: onTouchEvent>>>> ACTION_MOVE
ACTION_UP 分发路径
MainActivity: dispatchTouchEvent>>>> ACTION_UP
MainActivity: onTouchEvent>>>> ACTION_UP
发现分发起点是Activity,然后不断的dispatchTouchEvent(可能会onInterceptTouchEvent)到往子视图分发,直到最顶层的TextView,之后再onTouchEvent确认消费返回,这里发现个问题,除了ACTION_DOWN是完整分发,后续的ACTION_MOVE和ACTION_UP只分发到Activity
此时观看一下事件分发方法的相关状态,发现所有方法返回值都是false,这里看不出什么意思
LinearLayout1: onInterceptTouchEvent<<<< ACTION_DOWN, intercept:false
LinearLayout2: onInterceptTouchEvent<<<< ACTION_DOWN, intercept:false
MyTextView: onTouchEvent<<<< ACTION_DOWN, resumed:false
MyTextView: dispatchTouchEvent<<<< ACTION_DOWN, dispatched:false
LinearLayout2: onTouchEvent<<<< ACTION_DOWN, resumed:false
LinearLayout2: dispatchTouchEvent<<<< ACTION_DOWN, dispatched:false
LinearLayout1: onTouchEvent<<<< ACTION_DOWN, resumed:false
LinearLayout1: dispatchTouchEvent<<<< ACTION_DOWN, dispatched:false
MainActivity: onTouchEvent<<<< ACTION_DOWN, resumed:false
MainActivity: dispatchTouchEvent<<<< ACTION_DOWN dispatched:false
MainActivity: onTouchEvent<<<< ACTION_MOVE, resumed:false
MainActivity: dispatchTouchEvent<<<< ACTION_MOVE dispatched:false
MainActivity: onTouchEvent<<<< ACTION_MOVE, resumed:false
MainActivity: dispatchTouchEvent<<<< ACTION_MOVE dispatched:false
MainActivity: onTouchEvent<<<< ACTION_MOVE, resumed:false
MainActivity: dispatchTouchEvent<<<< ACTION_MOVE dispatched:false
MainActivity: onTouchEvent<<<< ACTION_UP, resumed:false
MainActivity: dispatchTouchEvent<<<< ACTION_UP dispatched:false
接下来我们测试每个方法的返回值
1. dispatchTouchEvent
LinearLayout1.dispatchTouchEvent 返回 true
,我们挑重要路径查看,这里发现LinearLayout1可以正常收到后续的ACTION_MOVE和ACTION_UP,所以dispatchTouchEvent
的返回值决定了分发的终点,如果返回false,它将不会再收到后续事件
ACTION_DOWN 分发路径
MainActivity: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout2: dispatchTouchEvent>>>> ACTION_DOWN
MyTextView: dispatchTouchEvent>>>> ACTION_DOWN
MyTextView: onTouchEvent>>>> ACTION_DOWN
LinearLayout2: onTouchEvent>>>> ACTION_DOWN
LinearLayout1: onTouchEvent>>>> ACTION_DOWN
ACTION_MOVE 分发路径
LinearLayout1: dispatchTouchEvent>>>> ACTION_MOVE
ACTION_MOVE 分发路径
LinearLayout1: dispatchTouchEvent>>>> ACTION_UP
LinearLayout1: onTouchEvent>>>> ACTION_UP
2. onInterceptTouchEvent
这个方法只有ViewGroup有,表示否是继续向子视图传递事件,我们直观感受一下即可
LinearLayout1. onInterceptTouchEvent 返回true
,发现事件分发只到LinearLayout1,由于LinearLayout1本身并没有消费该事件,因此该事件最终由Activity消化
MainActivity: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: onInterceptTouchEvent>>>> ACTION_DOWN
LinearLayout1: onTouchEvent>>>> ACTION_DOWN
MainActivity: onTouchEvent>>>> ACTION_DOWN
MainActivity: dispatchTouchEvent>>>> ACTION_MOVE
MainActivity: onTouchEvent>>>> ACTION_MOVE
MainActivity: dispatchTouchEvent>>>> ACTION_UP
MainActivity: onTouchEvent>>>> ACTION_UP
3. onTouchEvent
LinearLayout2.onTouchEvent 返回 true
,LinearLayout2可以正常收到后续事件,也就是分发会一直传递到MyTextView,但是消费事件只有LinearLayout2能收到,它的父视图将不会再收到消费事件,所以onTouchEvent
决定哪个视图消费事件,返回true的视图确认消费事件
// ACTION_DOWN 分发路径
MainActivity: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout1: onInterceptTouchEvent>>>> ACTION_DOWN
LinearLayout2: dispatchTouchEvent>>>> ACTION_DOWN
LinearLayout2: onInterceptTouchEvent>>>> ACTION_DOWN
MyTextView: dispatchTouchEvent>>>> ACTION_DOWN
MyTextView: onTouchEvent>>>> ACTION_DOWN
// ACTION_MOVE 分发路径
LinearLayout2: dispatchTouchEvent>>>> ACTION_MOVE
LinearLayout2: onTouchEvent>>>> ACTION_MOVE // 消费
// ACTION_UP 分发路径
LinearLayout2: dispatchTouchEvent>>>> ACTION_UP
LinearLayout2: onTouchEvent>>>> ACTION_UP // 消费
如果我们同时将LinearLayout2.onTouchEvent 返回 true
& MyTextView.onTouchEvent 返回 true
,后续日志会发现只有MyTextView能正常消费事件,这是因为子视图的onTouchEvent消费优先级大于父视图(外->内)
4.消费起点 ACTION_DOWN
LinearLayout2.onTouchEvent 中,我们只将ACTION_DOWN事件返回true,其他返回false,会发现该试图后续事件还是可以正常接受,这是因为ACTION_DOWN作为起点,对于此次事件是否消费有决定性的作用,ACTION_DOWN决定了消费后,传递链会建立,后续就会依据链路传递后边事件序列
5. 总结
1)ACTION_DOWN 作为事件的起点,决定了哪个视图消费事件
① ViewGroup 在接收到 ACTION_DOWN 事件时,其 dispatchTouchEvent 方法内部会先调用 onInterceptTouchEvent 判断是否要进行拦截,如果 onInterceptTouchEvent 方法返回了 false,则意味着其不打算拦截该事件,那么就会继续调用 child 的 dispatchTouchEvent 方法,继续重复以上步骤。如果拦截了,那么就会调用 onTouchEvent 进行消费
②如果 ViewGroup 自身拦截且消费了 ACTION_DOWN 事件,那么本次事件序列的后续事件就会都交由其进行处理(如果能接收得到的话),不会再调用其 onInterceptTouchEvent 方法来判断是否进行拦截,也不会再次遍历 child,dispatchTouchEvent 方法会直接调用 onTouchEvent 方法。这是为了尽量避免无效操作,提高系统的绘制效率
③如果根 ViewGroup 和内嵌的所有 ViewGroup 均没有拦截 ACTION_DOWN 事件的话,那么事件通过循环传递就会分发给最底层的 View。对于 View 来说,其不包含 onInterceptTouchEvent 方法,dispatchTouchEvent 方法会调用其 onTouchEvent 方法来决定是否消费该事件。如果返回 false,则意味着其不打算消费该事件,事件将依次调用父容器的 onTouchEvent 方法;返回 true 的话则意味着事件被其消费了,事件终止传递
④而不管 ViewGroup 有没有拦截 ACTION_DOWN 事件,只要其本身和所有 child 均没有消费掉 ACTION_DOWN 事件,即 dispatchTouchEvent 方法返回了 false,那么此 ViewGroup 就不会再接收到后续事件,后续事件会被 Activity 直接消化掉
⑤View 是否能接收到整个事件序列的消息主要就取决于其是否消费了 ACTION_DOWN 事件,ACTION_DOWN 事件是整个事件序列的起始点,View 必须消耗了起始事件才有机会完整处理整个事件序列
⑥ACTION_DOWN是一次事件分发起点,它是一个决定性的事件,一个 View只要其消费了ACTION_DOWN事件,即使 onTouchEvent 方法在处理每个后续事件时均返回了 false,都还是可以完整接收到整个事件序列的消息。后续事件会根据在处理 ACTION_DOWN 事件保留的引用链依次分发
2)ACTION_MOVE 作为事件序列中的一个环节,可以决定视图是否消费是否生效(离开视图可以修改pressed为false)
3)ACTION_UP 作为事件的终点,决定了视图消费操作是否真实响应(pressed是否为true)
4)处于上游的 ViewGroup 不关心到底是下游的哪个 ViewGroup 或者 View 消费了事件,只要下游的 dispatchTouchEvent 方法返回了 true,上游就会继续向下游下发后续事件
5)ViewGroup 和 View 对于每次事件序列的消费过程是独立的,即上一次事件序列的消费结果不影响新一次的事件序列
六、滑动冲突处理
1.内部拦截法
内部拦截法则是要求父容器不拦截任何事件,所有事件都传递给子 View,子 View 根据实际情况判断是自己来消费还是传回给父容器进行处理。该方式有几个注意点:
- 父容器不能拦截 ACTION_DOWN 事件,否则后续的触摸事件子 View 都无法接收到
- 滑动事件的舍取逻辑放在子 View 的 dispatchTouchEvent 方法中,如果父容器需要处理事件则调用 parent.requestDisallowInterceptTouchEvent(false) 方法让父容器去拦截事件
伪代码
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 让父容器不拦截 ACTION_DOWN 的后续事件
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
if (父容器需要此事件) { // 应该让父容器响应了
// 让父容器拦截后续事件
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
}
}
return super.dispatchTouchEvent(event)
}
2.外部拦截法
父容器根据实际情况在 onInterceptTouchEvent 方法中对触摸事件进行选择性拦截,如果判断到当前滑动事件自己需要,那么就拦截事件并消费,否则就交由子 View 进行处理。该方式有几个注意点:
- ACTION_DOWN 事件父容器不能进行拦截,否则根据 View 的事件分发机制,后续的 ACTION_MOVE 与 ACTION_UP 事件都将默认交由父容器进行处理
- 根据实际的业务需求,父容器判断是否需要处理 ACTION_MOVE 事件,如果需要处理则进行拦截消费,否则交由子 View 去处理
- 原则上 ACTION_UP 事件父容器不应该进行拦截,否则子 View 的 onClick 事件将无法被触发
伪代码
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
var intercepted = false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
intercepted = false
}
MotionEvent.ACTION_MOVE -> {
intercepted = if (满足拦截要求) {
true
} else {
false
}
}
MotionEvent.ACTION_UP -> {
intercepted = false
}
}
return intercepted
}
3.案例
现在有一个场景,ScrollView内嵌了一个ScrollView,两个ScrollView均需要滑动
xml如下
<?xml version="1.0" encoding="utf-8"?>
<com.nius.event.ExternalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#00f">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:id="@+id/eternal_view1"
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="@color/purple_500" />
<com.nius.event.InsideScrollView
android:id="@+id/inside_scroll_view"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="#f88">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:id="@+id/inside_view1"
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#444" />
<View
android:layout_width="match_parent"
android:layout_height="180dp"
android:background="#999" />
</LinearLayout>
</com.nius.event.InsideScrollView>
<View
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="@color/purple_200" />
</LinearLayout>
</com.nius.event.ExternalScrollView>
如果我们不对InsideScrollView、ExternalScrollView做任何事件相关操作,将会导致内部scrollView无法滑动,因为ScrollView默认拦截并消费滑动事件,导致子视图收不到该事件而无法滑动,先使用内部拦截法
1)内部拦截法解决
class InsideScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr)
override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
// 父容器不要拦截任何事件,只要事件能分发到当前View就正常分发,当前view正常消费(滑动)
// 只要DOWN事件处于当前视图范围,当前视图就消费,否则父视图走原本逻辑(滑动)
parent.requestDisallowInterceptTouchEvent(true)
...
return super.dispatchTouchEvent(motionEvent)
}
}
如果需要联动效果,比如子视图滑倒顶部或者底部时,转变为父视图滑动
class InsideScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
private var lastX = 0f
private var lastY = 0f
override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean {
val x = motionEvent.x
val y = motionEvent.y
when (motionEvent.action) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
val deltaX = x - lastX
val deltaY = y - lastY
if (abs(deltaX) < abs(deltaY)) { //上下滑动的操作
if (deltaY > 0) { //向下滑动
if (scrollY == 0) { //滑动到顶部了
parent.requestDisallowInterceptTouchEvent(false)
}
} else { //向上滑动
if (height + scrollY >= computeVerticalScrollRange()) { //滑动到底部了
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
}
}
lastX = x
lastY = y
return super.dispatchTouchEvent(motionEvent)
}
}
2)外部拦截法解决
class ExternalScrollView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
private var mDownPointX = 0f
private var mDownPointY = 0f
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
var intercepted = super.onInterceptTouchEvent(ev)
when(ev?.action){
MotionEvent.ACTION_DOWN -> {
intercepted = false
mDownPointX = ev.rawX
mDownPointY = ev.rawY
}
MotionEvent.ACTION_MOVE ->{
// 当ACTION_MOVE消息到来时,判断DOWN时的手指位置是不是在 InsideScrollView 的内部
// 如果在内部,则不拦截消息,让子视图去处理,否则自己拦截掉自己处理
if (isTouchPointInView(findViewById(R.id.inside_scroll_view), mDownPointX, mDownPointY)) {
intercepted = false
} else {
intercepted = true
}
}
}
return intercepted
}
private fun isTouchPointInView(view: View, x: Float, y: Float): Boolean {
val location = IntArray(2)
view.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
return y >= top && y <= bottom && x >= left && x <= right
}
}
外部拦截法处理子视图时,要获取子视图相关操作属性会麻烦一些,不过也有其特殊的运用场景,比如需要集中管理多个子视图的场景
3)总结
内部拦截法适合于子视图需要根据自身需求灵活处理事件的场景,能够提供更高的灵活性,但在复杂布局中可能会导致逻辑复杂和性能开销。
外部拦截法适合于需要集中管理事件拦截逻辑的场景,能够有效避免滑动冲突,但可能会导致灵活性不足和状态管理复杂
七、小tips
1. 手指按下后滑动离开视图,为什么不响应点击状态?
当我们手指按下并抬起时
- 如果手指仍然处于视图区域,那么正常响应视图的点击响应等操作
- 如果手指已经处于视图区域外,那么忽略本次事件的响应操作
这就是我们经常遇到的,如果点击了某个视图,然后主动滑倒视图外部,视图就不会再响应点击事件,这里是因为滑动时视图的ACTION_MOVE中检测到手指离开视图区域,将pressed状态设置成了false,ACTION_UP事件发现pressed是false,忽略本次事件
// 超出视图时记录 pressed
public boolean onTouchEvent(MotionEvent event) {
switch (action) {
case MotionEvent.ACTION_MOVE:
if (!pointInView(x, y, touchSlop)) { // 超出了视图
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false); // 取消按钮按下状态
...
}
public void setPressed(boolean pressed) {
if (pressed) {
...
} else {
mPrivateFlags &= ~PFLAG_PRESSED;; // 取消按压态标记
}
...
}
// 手指抬起后,发现没有按压状态,取消事件响应
public boolean onTouchEvent(MotionEvent event) {
switch (action) {
case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) { // 这里进不去
...
performClickInternal() // 回调 mOnClickListener.onClick(this);
...
}
mIgnoreNextUpEvent = false; // 忽略标记
...
}
手指滑动离开视图区域后,pressed设置为false是不可逆操作,可以测试发现,我们手指滑动离开按钮后再移动回来是不会再触发点击效果的
2. ACTION_CANCEL 为什么会存在
这个状态存在的原因是为了恢复状态,比如我们点击了一个按钮,按钮消费了ACTION_DOWN事件,然后状态高亮了,如果后续的ACTION_UP事件没有到来(被父视图拦截、ANR等原因导致后续事件丢失),此时按钮状态就没法恢复了,因此需要一个兜底状态来做一些重置恢复操作
可能许多同学会将ACTION_UP中手指滑动离开视图区域这个操作理解为ACTION_CANCEL操作,实际上只要操作不被拦截或者其他意外情况打断的情绪下,ACTION_DOWN - ACTION_UP 必定是一一对应的,滑动离开视图区域只是正常事件处理中的一个情形
ACTION_CANCEL什么时候回发生?
- 在子View处理事件的过程中,父View对事件拦截(这个很好理解)
- ACTION_DOWN初始化操作(每次新事件都要清楚上一次状态)
- 在子View处理事件的过程中被从父View中移除时(传递链消失)
- 子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时(视图从Window移除、传递链消失)
3. setOnClickListener
当给视图设置了OnClickListener后,视图的onTouchEvent会返回true,标记当前视图消费事件,因此设置点击事件监听相当于标记当前视图可以消费事件
4. ViewGroup.mFirstTouchTarget(TouchTarget)
ViewGroup派发事件到子视图的时候,由于可能存在多个子视图,要判断哪一个子视图能消费事件,通常需要便利子视图。为了提高效率,ViewGroup会记录onTouchEvent中ACTION_DOWN返回true的视图,后续的ACTION_MOVE、ACTION_UP等事件无需再做遍历操作。
5. 事件机制的复用池
另额点击、滑动等事件是非常高频且必须的操作,MotionEvent(gRecyclerTop)、TouchTarget(sRecycleBin)均设计了全局的复用池(链表)实现对象的创建回收,避免瞬间大量的临时对象创建和销毁带来性能损耗,这种设计也降低了GC压力
6. 事件分发为什么由内而外(由于父到子)
一方面分发过程肯定是要依据数据结构进行,视图树从树根DecorView开始;另一方面,大部分系统设计中越往外的视图挡住了内部的视图,在用户看不到内部视图的场景下响应事件不符合用户预期