前言

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

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

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

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

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

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

二、触摸事件与底层

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

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

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

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

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

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

四、触摸事件与多点触控

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

1 @RequiresApi(api = Build.VERSION_CODES.KITKAT)
2 @Override
3 public boolean onTouchEvent(MotionEvent event) {
4     Log.i(TAG, "event=" + MotionEvent.actionToString(event.getActionMasked()));
5     switch (event.getActionMasked()) {
6         ......
7     }
8     return super.onTouchEvent(event);
9 }

这里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:

 1 public class CustomDialog extends Dialog {
 2     public CustomDialog(Context context) {
 3         super(context);
 4         init();
 5     }
 6
 7     @RequiresApi(api = Build.VERSION_CODES.KITKAT)
 8     @Override
 9     public boolean onTouchEvent(MotionEvent event) {
10         if (MotionEvent.ACTION_OUTSIDE == event.getAction()) {
11             Log.i("songzheweiwang", MotionEvent.actionToString(event.getAction()));
12         }
13         return super.onTouchEvent(event);
14     }
15
16     private void init() {
17         setContentView(R.layout.dialog_outside);
18         //清空原有的flag
19         getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
20         //设置监听OutSide Touch
21         getWindow().setFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
22     }
23 }

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

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

点击界面的对话框以外的区域,可以看到如下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本身也提供了一些判断自身位置的方法,并且这些方法名称和功能都非常相似,这导致在实际开发中,很容易混淆。这里我们简单了解并辨别这些方法的功能,如下表所示:

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

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

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

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

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

 1 public class CustomView extends View {
 2     private static final String TAG = "CustomView";
 3
 4     public CustomView(Context context, @Nullable AttributeSet attrs) {
 5         super(context, attrs);
 6     }
 7
 8     @Override
 9     public boolean onTouchEvent(MotionEvent event) {
10         float viewLeft = getLeft();
11         float viewTop = getTop();
12         float viewX = getX();
13         float viewY = getY();
14         float eventX = event.getX();
15         float eventY = event.getY();
16         float rawX = event.getRawX();
17         float rawY = event.getRawY();
18         int index = event.getActionIndex();
19         float pointerX = event.getX(index);
20         float pointerY = event.getY(index);
21         Log.i(TAG, "viewLeft=" + viewLeft + ";viewTop=" + viewTop
22                 + ";\n viewX=" + viewX + ";viewY=" + viewY
23                 + ";\n eventX=" + eventX + ";eventY=" + eventY
24                 + ";\n rawX=" + rawX + ";rawY=" + rawY
25                 + ";\n index=" + index + ";pointerX=" + pointerX + ";pointerY=" + pointerY);
26         return super.onTouchEvent(event);
27     }
28 }

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

 1 <?xml version="1.0" encoding="utf-8"?>
 2 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
 3     android:layout_width="match_parent"
 4     android:layout_height="match_parent">
 5
 6     <com.example.demos.customviewdemo.CustomView
 7         android:layout_width="200dp"
 8         android:layout_height="200dp"
 9         android:layout_centerHorizontal="true"
10         android:layout_marginTop="100dp"
11         android:background="@android:color/darker_gray" />
12 </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详解

       【电容式触摸屏

07-05 14:42