【朝花夕拾】Android自定義View篇之(八)多點觸控(上)MotionEvent簡介

宋者為王發表於2019-07-05

前言

       在前面的文章中,介紹了不少觸控相關的知識,但都是基於單點觸控的,即一次只用一根手指。但是在實際使用App中,常常是多根手指同時操作,這就需要用到多點觸控相關的知識了。多點觸控是在Android2.0開始引入的,在現在使用的Android手機上都是支援多點觸控的。本系列文章將對常見的多點觸控相關的重點知識進行總結,並使用多點觸控來實現一些常見的效果,從而達到將理論知識付諸實踐的目的。本文作為本系列的第一篇,將主要介紹MotionEvent的一些基本知識,以及引入多點觸控。

 

 

一、觸控事件感應的產生原理

       在介紹多點觸控前,我們先了解一下現在手機螢幕觸控事件感應的原理。 當前手機使用的螢幕一般都是電容式觸控式螢幕,我們看看百度百科中對此的介紹:

       電容式觸控式螢幕技術是利用人體的電流感應進行工作的。當手指觸控在螢幕上時,由於人體電場,使用者和觸控式螢幕表面形成以一個耦合電容,對於高頻電流來說,電容是直接導體,於是手指從接觸點吸走一個很小的電流。這個電流分別從觸控式螢幕的四角上的電極中流出,並且流經這四個電極的電流與手指到四角的距離成正比,控制器通過對這四個電流比例的精確計算,得出觸控點的位置。 (摘自百度百科【電容式觸控式螢幕】)

       電容式觸控式螢幕感應觸控事件,和人體電場相關,這也就是為什麼用手指觸控時螢幕能有響應,但其它物體卻不行的原因。而早期的手機採用的是電阻式觸控式螢幕,當螢幕受到壓力時電阻有變化,通過電阻來感應觸控,所以除了手指外,其它物體也能讓螢幕產生響應。電容式觸控式螢幕支援多點觸控,但電阻式觸控式螢幕不能。

 

二、觸控事件與底層

       在文章【【朝花夕拾】Android自定義View篇之(六)Android事件分發機制(中)從原始碼分析事件分發邏輯及經常遇到的一些“詭異”現象】的開頭我們介紹過“事件的前世今生”,事件是從硬體感應,然後經過驅動、框架,然後到達View的。前面講過的內容這裡不再贅述,我們看看下面這份截圖:

       這是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()。如下所示:

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。

點選介面的對話方塊以外的區域,可以看到如下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() 獲取事件與螢幕頂部邊界的距離。

 

       通過上表,我們發現,最重要的是要搞清楚各個方法所參照的座標系。為了直觀瞭解各個方法獲取的值的含義,我們參照上面的表格和下圖進行理解。

這其中涉及到的三個座標系分別為:

  • 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詳解

       【電容式觸控式螢幕

 

相關文章