Android 中 View 的事件分发机制

在 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的事件方法来对事件源头做统一、全局的管理

具体来看,生产+消费过程应该是这样的

event2

二、事件处理

事件的处理包含分发、拦截和消费,这几个阶段需要分开看,这个过程类似双亲委派双向关系,分发的主要流向从父视图到子视图(可以拦截),消费过程则相反,从子视图到父视图。

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叠了三层子视图,运行效果截图如下


企业微信截图_b852ed9f-0f46-4ee4-811c-d6ab0ce59071.png

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. 手指按下后滑动离开视图,为什么不响应点击状态?

企业微信截图_b036551b-26b8-46da-bd2c-fa6e2262ef47.png

当我们手指按下并抬起时

  • 如果手指仍然处于视图区域,那么正常响应视图的点击响应等操作
  • 如果手指已经处于视图区域外,那么忽略本次事件的响应操作

这就是我们经常遇到的,如果点击了某个视图,然后主动滑倒视图外部,视图就不会再响应点击事件,这里是因为滑动时视图的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开始;另一方面,大部分系统设计中越往外的视图挡住了内部的视图,在用户看不到内部视图的场景下响应事件不符合用户预期

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容