最近工作室要求做一个从下面往上滑出来的抽屉。之前都是直接调用 AndroidX 的库,这次要自己写,其实问题主要就是要防止滑动冲突。所以就学习了一下安卓的触摸事件的传递过程。

我将要向你们介绍当用户手指按在屏幕上的时候,安卓开发者如何处理这个操作。

MotionEvent

MotionEvent 可以理解为一个事件的实体类,它主要带着三个信息:

  • 触摸事件的类型
  • 触摸事件的位置
  • 其他信息

事件类型

action 是事件中标记类型的变量。它可能有以下值:

action说明
MotionEvent.ACTION_DOWN第一个手指初次接触到屏幕
MotionEvent.ACTION_MOVE手指在屏幕上滑动
MotionEvent.ACTION_UP最后一个手指离开屏幕
MotionEvent.ACTION_CANCEL事件被拦截
MotionEvent.ACTION_OUTSIDE手指没有接触到控件区域
MotionEvent.ACTION_POINTER_DOWN非主要手指按下(之前已经有手指在屏幕)
MotionEvent.ACTION_POINTER_UP非主要手指抬起(之后还有手指在屏幕)

其中较常用的为前三个。

例如以下用法:

override fun onTouchEvent(ev: MotionEvent): Boolean {
    if(ev.action == MotionEvent.ACTION_DOWN){
        // 有手指按下时候调用的代码
    }
}

位置信息

主要为其中几个获取坐标的方法:

Function说明
getX()/getY()获取相对坐标(相对于父控件)
getRawX()/getRawY()获取绝对坐标(相对于整个 Context)

以上方法的坐标原点都在左上角,具体可以看下图:
上述方法都有重载,可以传入 手指ID 来获取当前指定手指的位置。

{(WSO2K0XJ5{3II1{FDU1IH.png

其他信息

其他信息就有很多了,各个手指的信息,接触面积的信息等。具体可以自行查找

事件传递

当触摸事件发生的时候,在 Activity 之前的传递过程对于软件开发没有啥帮助,所以本篇主要讲解的过程在 Activity 之后。

首先我们可以把控件抽象为一棵树,如下图,其中 Activity 是根节点,View 为叶结点,ViewGroup 可能是叶节点也可能是中间节点。

IJE(F[)RGJF7W%(~P)S3L3.png

其中 ActivityView 对于触摸事件有两个方法:

  1. 分发: dispatchTouchEventon
  2. 消费: onTouchEvent

ViewGroup 作为中间节点,除了以上两个还多了一个,一共是以下方法:

  1. 分发: dispatchTouchEventon
  2. 拦截: onInterceptTouchEvent
  3. 消费: onTouchEvent

首先明确一点,我们点击屏幕的时候,一次一定是点击到屏幕中某一个控件,而不可能多个。所以对于一次点击事件,在控件树中只会沿着一条往下的路径传播,例如下图:当我们最终点击到 View1 的时候,传递途径只可能是以下这一条黄色的路径,所以我们可以单独拿这一个条路径形成一个顺序结构来研究。

BR28Q[~C}OY{DUFU)JPK6O.png

0D@7XL746ODHMV)GV%){MP.png

传递图解

下图可以表示一条路径的传播路程

O{@EXK~IQ@(3C$DC3S5(B_E.png

伪代码

由上图可以看出来,对于 Activity ,首先调用子控件的分发方法,如果没有被消费则调用自身的 OnTouchEvent 方法,伪代码为:

/**
 * Activity 的 dispatchTouchEvent 伪代码
 * @return 是否消费
 */
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    var consume = false //是否消费
    val child = /*通过坐标计算获取位置的子控件*/

    // 调用子控件的 分发方法并获取是否消费
    if(child.dispatchTouchEvent(ev)){ // 如果子控件消费了
        consume = true;
    }else{// 子控件没消费,调用自己的 onTouchEvent
        consume = this.onTouchEvent(ev);
    }
    return consume
}

对于 ViewGroup 也是类似的处理,不过有一个拦截的过程:

/**
 * ViewGroup 的 dispatchTouchEvent 伪代码
 * @return 是否消费
 */
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    var consume = false //是否消费
    if(onInterceptTouchEvent(ev)){ //如果拦截
        consume this.onTouchEvent(ev); 
    }else{
        val child = /*通过坐标计算获取位置的子控件*/
        if(child != null || child.dispatchTouchEvent(ev)){ // 如果点击的位置没有子控件,或者子控件消费了
            consume = true;
        }else{// 子控件没消费,调用自己的 onTouchEvent
            consume = this.onTouchEvent(ev);
        }
    }
    return consume
}

对于 View ,因为 View 在控件树中是叶节点,没有下一级了,所以都是直接进行消费:

/**
 * View 的 dispatchTouchEvent 伪代码
 * @return 是否消费
 */
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    return this.onTouchEvent(ev);
}

其他说明

其实以上过程主要是 MotionEvent.ACTION_DOWN 的过程,对于其他过程,遵循着以下规则:

  1. 一个控件如果 MotionEvent.ACTION_DOWNdispatchTouchEvent 返回 false,则一直到下一个 MotionEvent.ACTION_DOWN 为止它不会在接受到任何事件。要注意的是,如果子控件消费了该事件,父控件的 dispatchTouchEvent 也是返回 true
  2. 一个 ViewGroup 如果在 MotionEvent.ACTION_DOWNonInterceptTouchEvent 方法返回 false ,则一直到下一个 MotionEvent.ACTION_DOWN 为止 onInterceptTouchEvent 将不会被调用,永远不会拦截,当然可以调用 ViewGrouprequestDisallowInterceptTouchEvent 方法强制拦截。
  3. 在根据坐标寻找子控件的时候,只会根据 MotionEvent.ACTION_DOWN 时的坐标。即如果你按下控件 A 然后拖动到 A 之外。在之外的 MotionEvent.ACTION_MOVE 事件依然会传递到控件 A,直到下一次 MotionEvent.ACTION_DOWN 将坐标刷新。

结语

终于写完了,写这玩意太费脑了。其实本文章给出的过程并不是传递的全部。事件传递还有很多细节。不过对于平时开发来说,以上过程足矣。更多细节可以自己去看源码了解。

下一篇将会介绍一下滑动事件传递的实战,实现一个屏幕最下面划出的抽屉。并防止滑动冲突。