【朝花夕拾】Android自定义View篇之(八)多点触控(上)MotionEvent简介

时间:2022-09-27 09:26:12

前言

在前面的文章中,介绍了不少触摸相关的知识,但都是基于单点触控的,即一次只用一根手指。但是在实际使用App中,常常是多根手指同时操作,这就需要用到多点触控相关的知识了。多点触控是在Android2.0开始引入的,在现在使用的Android手机上都是支持多点触控的。本文将对常见的多点触控相关的重点知识进行总结,并使用多点触控来实现一些常见的效果,从而达到将理论知识付诸实践的目的。

一、触摸事件感应的产生原理

在介绍多点触控前,我们先了解一下现在手机屏幕触摸事件感应的原理。 当前手机使用的屏幕一般都是电容式触摸屏,我们看看百度百科中对此的介绍:

电容式触摸屏技术是利用人体的电流感应进行工作的。当手指触摸在屏幕上时,由于人体电场,用户和触摸屏表面形成以一个耦合电容,对于高频电流来说,电容是直接导体,于是手指从接触点吸走一个很小的电流。这个电流分别从触摸屏的四角上的电极中流出,并且流经这四个电极的电流与手指到四角的距离成正比,控制器通过对这四个电流比例的精确计算,得出触摸点的位置。 (摘自百度百科【电容式触摸屏】)

电容式触摸屏感应触摸事件,和人体电场相关,这也就是为什么用手指触摸时屏幕能有响应,但其它物体却不行的原因。而早期的手机采用的是电阻式触摸屏,当屏幕受到压力时电阻有变化,通过电阻来感应触摸,所以除了手指外,其它物体也能让屏幕产生响应。电容式触摸屏支持多点触控,但电阻式触摸屏不能。

二、触摸事件与底层

在文章【【朝花夕拾】Android自定义View篇之(六)Android事件分发机制(中)从源码分析事件分发逻辑及经常遇到的一些“诡异”现象】的开头我们介绍过“事件的前世今生”,事件是从硬件感应,然后经过驱动、框架,然后到达View的。前面讲过的内容这里不再赘述,我们看看下面这份截图:

【朝花夕拾】Android自定义View篇之(八)多点触控(上)MotionEvent简介

这是MotionEvent类中跟踪与事件相关的主要方法的结果,几乎都是很快就调到了native层。通过这些方法,我们可以直观感受到事件与底层的密切联系。

三、事件输入设备以及MotionEvent中对应的事件说明

随着Android系统版本的提升,以及Android硬件设备的发展,事件输入设备和对应的事件特点也在不断发生着变化。轨迹球出现在很早的手机中,后来去掉了;多点触控也是在Android2.0开始支持的......咱们这里不一一列举,当然,大家也不关心这些细节。这里我汇总了目前我知道的一些事件输入设备,以及在MotionEvent中封装的对应的响应事件。

如下表格显示了它们大概的对应关系,由于我使用过的设备有限,所以有些对应设备的对应关系不太确定,下表中在括号内加了“?”。注意我这里的措词是“大概”,因为下面有些对应关系可能有交叉的情况等。本文关注的重点是多点触控,其它的这里咱们只做了解即可。

输入设备 响应事件 事件常量值 事件说明

单点触控/
触控笔/
多点触控/
橡皮檫(?)

ACTION_DOWN 0 第一个手指初次接触到屏幕时触发。
ACTION_UP 1 手指在屏幕上滑动时触发,会多次触发。
ACTION_MOVE 2 最后一个手指离开屏幕时触发。
ACTION_CANCEL 3 当前的手势被中断时触发。
ACTION_OUTSIDE 4 事件发生在UI边界之外时触发。
ACTION_POINTER_DOWN 5 有非主要的手指按下(即按下之前已经有手指在屏幕上)。
ACTION_POINTER_UP 6 有非主要的手指抬起(即抬起之后仍然有手指在屏幕上)。
鼠标/轨迹球(?) ACTION_HOVER_MOVE 7 指针在窗口或者View区域移动,但没有按下。
ACTION_SCROLL 8 滚轮滚动,可以触发水平滚动或垂直滚动
ACTION_HOVER_ENTER 9 指针移入到窗口或者View区域,但没有按下。
ACTION_HOVER_EXIT 10 指针移出到窗口或者View区域,但没有按下。

键盘/操纵杆(?)/
遥控器/
游戏控制器(游戏手柄)

ACTION_BUTTON_PRESS 11 按钮被按下
ACTION_BUTTON_RELEASE 12 按钮被释放
多点触控 ACTION_POINTER_1_DOWN 0x0005 第 2 个手指按下,android2.2后已废弃,不推荐使用。
ACTION_POINTER_2_DOWN 0x0105 第 3 个手指按下,android2.2后已废弃,不推荐使用。
ACTION_POINTER_3_DOWN 0x0205 第 4 个手指按下,android2.2后已废弃,不推荐使用。
ACTION_POINTER_1_UP 0x0006 第 2 个手指抬起,android2.2后已废弃,不推荐使用。
ACTION_POINTER_2_UP 0x0106 第 3 个手指抬起,android2.2后已废弃,不推荐使用。
ACTION_POINTER_3_UP 0x0206 第 4 个手指抬起,android2.2后已废弃,不推荐使用。

四、触摸事件与多点触控

前面我们在处理单点触控问题的时候,是在onTouchEvent(MotionEvent event)方法中通过使用event.getAction()来获取事件常量进行判断的。在Android2.0开始,要获取多点触控的事件,需要使用event.getActionMask()。如下所示:

 @RequiresApi(api = Build.VERSION_CODES.KITKAT)
 @Override
 public boolean onTouchEvent(MotionEvent event) {
     Log.i(TAG, "event=" + MotionEvent.actionToString(event.getActionMasked()));
     switch (event.getActionMasked()) {
         ......
     }
     return super.onTouchEvent(event);
 }

这里MotionEvent.actionToString(int)是系统提供的方法,可以将int表示的事件转为字符串,方便观察。方法的源码,读者可以自己去看看,很简单。

实际上在现在的系统版本中event.getAction()仍然能获取多指事件,这些获取的事件在上述表格中有说明,即上表中ACTION_POINTER_1_DOWN到ACTION_POINTER_3_UP,如果手指更多,事件也会更多。但是这个用法在Android2.0开始就被废弃了,现在需要兼容到2.0以下的场景太少了,所以这些过时的做法就不再介绍了,只要知道有这么回事就可以了。

这一节介绍使用event.getActionMask()方法后获取的几个触摸相关的事件。ACTION_DOWN和ACTION_UP前面的文章已经介绍过多次了,前的表格中也有说明,这里就不赘述了。

1、ACTION_CANCEL

这个事件在整个事件流被中断时会调用,比如父布局把ACTION_DOWN事件分发给了子View,但后面的MOVE和UP事件却给拦截时,子View中会产生CANCEL事件。ACTION_CANCEL事件和ACTION_UP事件总有一个会产生,实际上不少场景下会把ACTION_CANCEL当做ACTION_UP对待,来处理当前的事件流。在前面的文章【【朝花夕拾】Android自定义View篇之(六)Android事件分发机制(中)从源码分析事件分发逻辑及经常遇到的一些“诡异”现象】的第四节介绍requestDisallowInterceptTouchEvent(true)的作用时,就演示过ACTION_CANCEL的产生,这里不赘述了,不明白的可以去这篇文章看看。

还有一种常见的情形,ListView的使用场景。当手指触摸ListView时,会把ACTION_DOWN事件分发给ItemView,但是当手指开始滑动时,ListView发现这个时候需要自己消费这个滑动事件了,于是就把后续的MOVE和UP事件给拦截掉。ItemView被调侃了,绝望之下只能调用ACTION_CANCEL事件了。

这个事件算是一种比较特殊的事件了。

2、ACTION_OUTSIDE

这个事件比ACTION_CANCEL更特殊,一般很难触发。官方的介绍说是事件发生UI控件边界之外时触发,但通过实验,死活都触发不了这个事件。事实上这个事件出现的场景比较少见,我目前知道PopWindow和Dialog使用时可能触发这个场景。这里简单介绍一下使用Dialog时触发该事件的场景。

先自定义一个如下的Dialog:

 public class CustomDialog extends Dialog {
     public CustomDialog(Context context) {
         super(context);
         init();
     }

     @RequiresApi(api = Build.VERSION_CODES.KITKAT)
     @Override
     public boolean onTouchEvent(MotionEvent event) {
         if (MotionEvent.ACTION_OUTSIDE == event.getAction()) {
             Log.i("songzheweiwang", MotionEvent.actionToString(event.getAction()));
         }
         return super.onTouchEvent(event);
     }

     private void init() {
         setContentView(R.layout.dialog_outside);
         //清空原有的flag
         getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
         //设置监听OutSide Touch
         getWindow().setFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
     }
 }

注意第19行和第21行,需要设置相应的flag。

【朝花夕拾】Android自定义View篇之(八)多点触控(上)MotionEvent简介

点击界面的对话框以外的区域,可以看到如下log(对话框的显示和布局比较简单,这里就不贴出来了):

07-04 07:22:57.719 15647-15647/com.example.demos I/songzheweiwang: ACTION_OUTSIDE

3、ACTION_POINTER_DOWN

第二根手指以及更多的手指触摸时都会触发这个事件,不能从这个事件中判断是第几根手指。每根手指的事件都封装在MotionEvent中了,要想判断是第几根手指,需要结合MotionEvent提供的getActionIndex(),getPointerId(int),findPointerIndex(int)等方法来确定,具体的使用方法后面会做详细介绍。

4、ACTION_MOVE

无论是哪根手指移动,都会触发该事件。

5、ACTION_POINTER_UP

只要抬起的手指不是最后一根,就会触发这个事件,同样无法直接判断是第几根手指抬起来的。

五、获取事件的位置

在处理多点触控的时候,往往需要获取事件发生点的位置信息来完成一些效果。MotionEvent提供了多个用于获取事件位置的方法,一般处理事件是在View中来完成的,View本身也提供了一些判断自身位置的方法,并且这些方法名称和功能都非常相似,这导致在实际开发中,很容易混淆。这里我们简单了解并辨别这些方法的功能,如下表所示:

 
研究对象 方法名称 方法作用说明
View getLeft() 获取该View左边界与直接父布局左边界的距离。以直接父布局左上顶点为原点的坐标系为参照。
getTop() 获取该View上边界与直接父布局上边界的距离。
getX() 获取该View左上顶点在坐标系上的X坐标值。参照的坐标系同上。
getY() 获取该View左上顶点在坐标系上的Y坐标值。
MotionEvent getX() 获取事件相对于所在View的X坐标值。即以所在View的左上顶点为原点的坐标系为参照。
getY() 获取事件相对于所在View的Y坐标值。
getX(int pointerIndex) 获取给定pointerIndex的事件的X坐标值。该值也是相对于所在View而言的。
getY(int pointerIndex) 获取给定pointerIndex的事件的Y坐标值。
getRawX() 获取事件与屏幕左边界的距离。即以屏幕左上角为原点的坐标系为参照。
getRawY() 获取事件与屏幕顶部边界的距离。

通过上表,我们发现,最重要的是要搞清楚各个方法所参照的坐标系。为了直观了解各个方法获取的值的含义,我们参照上面的表格和下图进行理解。

【朝花夕拾】Android自定义View篇之(八)多点触控(上)MotionEvent简介

这其中涉及到的三个坐标系分别为:

  • View的getX()/getY()/getLeft()/getTop()所参照的,都是以直接父控件的左上角顶点为原点的坐标系,即图中标注的坐标系。这里getX()和getLeft(),getY()和getTop()的返回值是一样的。
  • MotionEvent的getX()/getY()/getX(int pointerIndx)/getY(int pointerIndex)所参照的,是以当前所在的View的左上角顶点为原点的坐标系。后面两个方法,是用于多点触控中获取对应事件的坐标位置的,后面会再讲到。
  • getRawX()/getRawY()所参照的,是以整个屏幕左上角顶点为原点的坐标系。getRawY()的值是包含了标题栏和状态栏高度的。

咱们用数据说话,这里看看演示结果。自定义一个view,在onTouchEvent方法中打印出上述各个方法获取的值。

 public class CustomView extends View {
     private static final String TAG = "CustomView";

     public CustomView(Context context, @Nullable AttributeSet attrs) {
         super(context, attrs);
     }

     @Override
     public boolean onTouchEvent(MotionEvent event) {
         float viewLeft = getLeft();
         float viewTop = getTop();
         float viewX = getX();
         float viewY = getY();
         float eventX = event.getX();
         float eventY = event.getY();
         float rawX = event.getRawX();
         float rawY = event.getRawY();
         int index = event.getActionIndex();
         float pointerX = event.getX(index);
         float pointerY = event.getY(index);
         Log.i(TAG, "viewLeft=" + viewLeft + ";viewTop=" + viewTop
                 + ";\n viewX=" + viewX + ";viewY=" + viewY
                 + ";\n eventX=" + eventX + ";eventY=" + eventY
                 + ";\n rawX=" + rawX + ";rawY=" + rawY
                 + ";\n index=" + index + ";pointerX=" + pointerX + ";pointerY=" + pointerY);
         return super.onTouchEvent(event);
     }
 }

布局效果如前面的截图所示,

 <?xml version="1.0" encoding="utf-8"?>
 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent">

     <com.example.demos.customviewdemo.CustomView
         android:layout_width="200dp"
         android:layout_height="200dp"
         android:layout_centerHorizontal="true"
         android:layout_marginTop="100dp"
         android:background="@android:color/darker_gray" />
 </RelativeLayout>

触摸界面中的自定义View,抓取ACTION_DOWN事件的log如下所示:

viewLeft=240.0;viewTop=300.0;
viewX=240.0;viewY=300.0;
eventX=387.0;eventY=424.0;
rawX=627.0;rawY=1003.0;
index=0;pointerX=387.0;pointerY=424.0

当前的测试机density=3.0,且标题栏和状态栏的高度值之和为279px。通过打印结果中正好rawY = eventY + viewY + 279,和前面给的结论对应上了。

这里需要注意的是getX()和getY()这个方法,在单点触摸的时候很好理解,因为同时只有一个事件,但在多点触摸中,就不太好理解了。如下是两个手指触摸捕捉到的log:

ACTION_DOWN
viewLeft=240.0;viewTop=300.0;viewX=240.0;viewY=300.0;eventX=380.0;eventY=215.0;rawX=620.0;rawY=794.0;index=0;pointerX=380.0;pointerY=215.0
ACTION_POINTER_DOWN(0)
viewLeft=240.0;viewTop=300.0;viewX=240.0;viewY=300.0;eventX=380.0;eventY=215.0;rawX=620.0;rawY=794.0;index=1;pointerX=206.0;pointerY=364.0
ACTION_POINTER_UP(0)
viewLeft=240.0;viewTop=300.0;viewX=240.0;viewY=300.0;eventX=380.0;eventY=215.0;rawX=620.0;rawY=794.0;index=0;pointerX=380.0;pointerY=215.0
ACTION_UP
viewLeft=240.0;viewTop=300.0;viewX=240.0;viewY=300.0;eventX=206.0;eventY=364.0;rawX=446.0;rawY=943.0;index=0;pointerX=206.0;pointerY=364.0

前三个事件时,eventX和eventY的值是一样的。ACTION_POINTER_DOWN(0)表示有第二根手指按下了,ACTION_POINTER_UP(0)表示其中一根手指抬起来了。按照我们的理解,另外一个手指按下了,eventX和eventY应该记录的是第二根手指按下的事件的坐标才对,不可能和第一根手指按下的事件坐标一样。所以这里就是需要着重注意的地方,我们先看看官网API中对它的描述:

public float getX ()
getX(int) for the first pointer index (may be an arbitrary pointer identifier).

描述中说,该方法获取的是第一个pointerIndex对应事件的坐标,即pointerIndex = 0对应的手指的触摸事件坐标(这里我是根据实验的结果和官网的说明来下的结论,不保证完全正确,请注意)。括号中也补充说明了,也有可能是一个随意的Pointer标识符。看到这里,我们应该可以明白上述log中的现象了吧。

结语

由于MotionEvent和多点触控相关的知识点比较多,所以一篇文章很难讲主要知识点介绍完。本文主要介绍了MotionEvent的一些基础知识点,以及引入多点触控。在后面系列文章中,会着重介绍多点触控相关的知识点,以及通过多点触控解决实际工作中的问题。

同样,如果有描述不妥或者不准确的地方,欢迎来拍砖,感谢!

参看文章

安卓自定义View进阶-MotionEvent详解

电容式触摸屏