前言
在前面的文章中,介紹了不少觸控相關的知識,但都是基於單點觸控的,即一次只用一根手指。但是在實際使用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的一些基礎知識點,以及引入多點觸控。在後面系列文章中,會著重介紹多點觸控相關的知識點,以及通過多點觸控解決實際工作中的問題。
同樣,如果有描述不妥或者不準確的地方,歡迎來拍磚,感謝!
參看文章
【電容式觸控式螢幕】