安卓自定義View進階-MotionEvent詳解
Android MotionEvent 詳解,之前用了兩篇文章 事件分發機制原理 和 事件分發機制詳解 來講解事件分發,而作為事件分發主角之一的 MotionEvent 並沒有過多的說明,本文就帶大家瞭解 MotionEvent 的相關內容,簡要介紹觸控事件,主要包括 單點觸控、多點觸控、滑鼠事件 以及 getAction() 和 getActionMasked() 的區別。
Android 將所有的輸入事件都放在了 MotionEvent 中,隨著安卓的不斷髮展壯大,MotionEvent 也開始變得越來越複雜,下面是我自己整理的 MotionEvent 大事記:
版本號 | 更新內容 |
---|---|
Android 1.0 (API 1 ) | 支援單點觸控和軌跡球的事件。 |
Android 1.6 (API 4 ) | 支援手勢。 |
Android 2.0 (API 5 ) | 支援多點觸控。 |
Android 3.1 (API 12) | 支援觸控筆,滑鼠,鍵盤,操縱桿,遊戲控制器等輸入工具。 |
以上僅僅是簡要的說明幾次比較大的變動,細小的修復和更新不計其數,此處就不一一列出了,反正也沒人關心這些東西。
MotionEvent 負責集中處理所有型別裝置的輸入事件,但是由於某些裝置使用的機率較小本文會忽略講解,或者簡要講解,例如:
1、軌跡球只出現在最早的裝置上,現代的裝置上已經見不到了,本文不再敘述。
2、觸控筆和手指處理流程基本相同,不再多說。
3、滑鼠在手機上使用概率也比較小,會在文末簡要介紹。
單點觸控
單點觸控就非常簡單啦,入門的工程師都會用,上一篇文章也簡要介紹過,主要涉及以下幾個事件:
事件 | 簡介 |
---|---|
ACTION_DOWN | 手指 初次接觸到螢幕 時觸發。 |
ACTION_MOVE | 手指 在螢幕上滑動 時觸發,會多次觸發。 |
ACTION_UP | 手指 離開螢幕 時觸發。 |
ACTION_CANCEL | 事件 被上層攔截 |
ACTION_OUTSIDE | 手指 不在控制元件區域 時觸發。 |
和以下的幾個方法:
方法 | 簡介 |
---|---|
getAction() | 獲取事件型別。 |
getX() | 獲得觸控點在當前 View 的 X 軸座標。 |
getY() | 獲得觸控點在當前 View 的 Y 軸座標。 |
getRawX() | 獲得觸控點在整個螢幕的 X 軸座標。 |
getRawY() | 獲得觸控點在整個螢幕的 Y 軸座標。 |
關於
get
和getRaw
的區別可以參考這一篇文章 安卓自定義View基礎-座標系
單點觸控一次簡單的互動流程是這樣的:
手指落下(ACTION_DOWN) -> 多次移動(ACTION_MOVE) -> 離開(ACTION_UP)
- 本次事例中 ACTION_MOVE 有多次觸發。
- 如果僅僅是單擊(手指按下再擡起),不會觸發 ACTION_MOVE。
針對單點觸控的事件處理一般是這樣寫的:
@Override
public boolean onTouchEvent(MotionEvent event) {
// ▼ 注意這裡使用的是 getAction(),先埋一個小尾巴。
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
// 手指按下
break;
case MotionEvent.ACTION_MOVE:
// 手指移動
break;
case MotionEvent.ACTION_UP:
// 手指擡起
break;
case MotionEvent.ACTION_CANCEL:
// 事件被攔截
break;
case MotionEvent.ACTION_OUTSIDE:
// 超出區域
break;
}
return super.onTouchEvent(event);
}
相信小夥伴對此已經非常熟悉了,經常使用的東西,我也不囉嗦了。
但其中有兩個比較特殊的事件: ACTION_CANCEL
和 ACTION_OUTSIDE
。
為什麼說特殊呢,因為它們是由程式觸發而產生的,而且觸發條件也非常特殊,通常情況下即便不處理這兩個事件也沒有什麼問題。接下來我們就扒一扒它們的真面目:
ACTION_CANCEL
ACTION_CANCEL
的觸發條件是事件被上層攔截,然而我們在 事件分發機制原理 一文中瞭解到當事件被上層 View 攔截的時候,ChildView 是收不到任何事件的,ChildView 收不到任何事件,自然也不會收到 ACTION_CANCEL
了,所以說這個 ACTION_CANCEL
的正確觸發條件並不是這樣,那麼是什麼呢?
事實上,只有上層 View 回收事件處理權的時候,ChildView 才會收到一個 ACTION_CANCEL
事件。
這樣說可能不太容易理解,咱舉個例子?
例如:上層 View 是一個 RecyclerView,它收到了一個
ACTION_DOWN
事件,由於這個可能是點選事件,所以它先傳遞給對應 ItemView,詢問 ItemView 是否需要這個事件,然而接下來又傳遞過來了一個ACTION_MOVE
事件,且移動的方向和 RecyclerView 的可滑動方向一致,所以 RecyclerView 判斷這個事件是滾動事件,於是要收回事件處理權,這時候對應的 ItemView 會收到一個ACTION_CANCEL
,並且不會再收到後續事件。通俗一點?
RecyclerView:兒砸,這裡有一個
ACTION_DOWN
你看你要不要。
ItemView :好嘞,我看看。
RecyclerView:噫?居然是移動事件ACTION_MOVE
,我要滾起來了,兒砸,我可能要把你送去你姑父家(快取區)了,在這之前給你一個ACTION_CANCEL
,你要收好啊。
ItemView :……這是實際開發中最有可能見到
ACTION_CANCEL
的場景了。
ACTION_OUTSIDE
ACTION_OUTSIDE
的觸發條件更加奇葩,從字面上看,outside 意思不就是超出區域麼?然而不論你如何滑動超出控制元件區域都不會觸發 ACTION_OUTSIDE
這個事件。相信很多魔法師都對此很是疑惑,說好的超出區域呢?
實際上這個事件根本就不是在這裡用的,看官方解釋(裝一下逼):
A movement has happened outside of the normal bounds of the UI element. This does not provide a full gesture, but only the initial location of the movement/touch.
一個觸控事件已經發生了UI元素的正常範圍之外。因此不再提供完整的手勢,只提供 運動/觸控 的初始位置。
我們知道,正常情況下,如果初始點選位置在該檢視區域之外,該檢視根本不可能會收到事件,然而,萬事萬物都不是絕對的,肯定還有一些特殊情況,你可曾還記得點選 Dialog 區域外關閉嗎?Dialog 就是一個特殊的檢視(沒有佔滿螢幕大小的視窗),能夠接收到檢視區域外的事件(雖然在通常情況下你根本用不到這個事件),除了 Dialog 之外,你最可能看到這個事件的場景是懸浮窗,當然啦,想要接收到檢視之外的事件需要一些特殊的設定。
設定檢視的 WindowManager 佈局引數的 flags為
FLAG_WATCH_OUTSIDE_TOUCH
,這樣點選事件發生在這個檢視之外時,該檢視就可以接收到一個ACTION_OUTSIDE
事件。參見StackOverflow:How to dismiss the dialog with click on outside of the dialog?
由於這個事件用到的機率比較小,此處就不展開敘述了,以後用到的時候再詳細講解。
多點觸控
Android 在 2.0 版本的時候開始支援多點觸控,一旦出現了多點觸控,很多東西就突然之間變得麻煩起來了,首先要解決的問題就是 多個手指同時按在螢幕上,會產生很多的事件,這些事件該如何區分呢?
為了區分這些事件,工程師們用了一個很簡單的辦法--編號,當手指第一次按下時產生一個唯一的號碼,手指擡起或者事件被攔截就回收編號,就這麼簡單。
第一次按下的手指特殊處理作為主指標,之後按下的手指作為輔助指標,然後隨之衍生出來了以下事件(注意增加的事件和事件簡介的變化):
事件 | 簡介 |
---|---|
ACTION_DOWN | 第一個 手指 初次接觸到螢幕 時觸發。 |
ACTION_MOVE | 手指 在螢幕上滑動 時觸發,會多次觸發。 |
ACTION_UP | 最後一個 手指 離開螢幕 時觸發。 |
ACTION_POINTER_DOWN | 有非主要的手指按下(即按下之前已經有手指在螢幕上)。 |
ACTION_POINTER_UP | 有非主要的手指擡起(即擡起之後仍然有手指在螢幕上)。 |
以下事件型別不推薦使用 | ------------------ |
第 2 個手指按下,已廢棄,不推薦使用。 | |
第 3 個手指按下,已廢棄,不推薦使用。 | |
第 4 個手指按下,已廢棄,不推薦使用。 | |
第 2 個手指擡起,已廢棄,不推薦使用。 | |
第 3 個手指擡起,已廢棄,不推薦使用。 | |
第 4 個手指擡起,已廢棄,不推薦使用。 |
和以下方法:
方法 | 簡介 |
---|---|
getActionMasked() | 與 getAction() 類似,多點觸控必須使用這個方法獲取事件型別。 |
getActionIndex() | 獲取該事件是哪個指標(手指)產生的。 |
getPointerCount() | 獲取在螢幕上手指的個數。 |
getPointerId(int pointerIndex) | 獲取一個指標(手指)的唯一識別符號ID,在手指按下和擡起之間ID始終不變。 |
findPointerIndex(int pointerId) | 通過PointerId獲取到當前狀態下PointIndex,之後通過PointIndex獲取其他內容。 |
getX(int pointerIndex) | 獲取某一個指標(手指)的X座標 |
getY(int pointerIndex) | 獲取某一個指標(手指)的Y座標 |
由於多點觸控部分涉及內容比較多,也很複雜,我準備單獨用一篇文章進行詳細敘述,所以這裡只敘述一些基礎的內容作為鋪墊:
getAction() 與 getActionMasked()
當多個手指在螢幕上按下的時候,會產生大量的事件,如何在獲取事件型別的同時區分這些事件就是一個大問題了。
一般來說我們可以通過為事件新增一個int型別的index屬性來區分,但是我們知道谷歌工程師是有潔癖的(在 自定義View分類與流程 的onMeasure中已經見識過了),為了新增一個通常數值不會超過10的index屬性就浪費一個int大小的空間簡直是不能忍受的,於是工程師們將這個index屬性和事件型別直接合並了。
int型別共32位(0x00000000),他們用最低8位(0x000000ff)表示事件型別,再往前的8位(0x0000ff00)表示事件編號,以手指按下為例講解數值是如何合成的:
ACTION_DOWN 的預設數值為 (0x00000000)
ACTION_POINTER_DOWN 的預設數值為 (0x00000005)
手指按下 | 觸發事件(數值) |
---|---|
第1個手指按下 | ACTION_DOWN (0x00000000) |
第2個手指按下 | ACTION_POINTER_DOWN (0x00000105) |
第3個手指按下 | ACTION_POINTER_DOWN (0x00000205) |
第4個手指按下 | ACTION_POINTER_DOWN (0x00000305) |
注意:
上面表格中用粗體標示出的數值,可以看到隨著按下手指數量的增加,這個數值也是一直變化的,進而導致我們使用 getAction()
獲取到的數值無法與標準的事件型別進行對比,為了解決這個問題,他們建立了一個 getActionMasked()
方法,這個方法可以清除index數值,讓其變成一個標準的事件型別。
1、多點觸控時必須使用 getActionMasked()
來獲取事件型別。
2、單點觸控時由於事件數值不變,使用 getAction()
和 getActionMasked()
兩個方法都可以。
3、使用 getActionIndex() 可以獲取到這個index數值。不過請注意,getActionIndex() 只在 down 和 up 時有效,move 時是無效的。
目前來說獲取事件型別使用 getActionMasked()
就行了,但是如果一定要編譯時相容古董版本的話,可以考慮使用這樣的寫法:
final int action = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO)
? event.getActionMasked()
: event.getAction();
switch (action){
case MotionEvent.ACTION_DOWN:
// TODO
break;
}
PointId
雖然前面剛剛說了一個 actionIndex,可以使用 getActionIndex() 獲得,但通過 actionIndex 字面意思知道,這個只表示事件的序號,而且根據其說明文件解釋,這個 ActionIndex 只有在手指按下(down)和擡起(up)時是有用的,在移動(move)時是沒有用的,事件追蹤非常重要的一環就是移動(move),然而它卻沒卵用,這也太不實在了 ( ̄Д ̄)ノ
鄭重宣告:追蹤事件流,請認準 PointId,這是唯一官方指定標準,不要相信 ActionIndex 那個小婊砸。
PointId 在手指按下時產生,手指擡起或者事件被取消後消失,是一個事件流程中唯一不變的標識,可以在手指按下時 通過 getPointerId(int pointerIndex)
獲得。 (引數中的 pointerIndex 就是 actionIndex)
關於事件流的追蹤等問題在講解多點觸控時再詳細講解。
歷史資料(批處理)
由於我們的裝置非常靈敏,手指稍微移動一下就會產生一個移動事件,所以移動事件會產生的特別頻繁,為了提高效率,系統會將近期的多個移動事件(move)按照事件發生的順序進行排序打包放在同一個 MotionEvent 中,與之對應的產生了以下方法:
事件 | 簡介 |
---|---|
getHistorySize() | 獲取歷史事件集合大小 |
getHistoricalX(int pos) | 獲取第pos個歷史事件x座標 (pos < getHistorySize()) |
getHistoricalY(int pos) | 獲取第pos個歷史事件y座標 (pos < getHistorySize()) |
getHistoricalX (int pin, int pos) | 獲取第pin個手指的第pos個歷史事件x座標 (pin < getPointerCount(), pos < getHistorySize() ) |
getHistoricalY (int pin, int pos) | 獲取第pin個手指的第pos個歷史事件y座標 (pin < getPointerCount(), pos < getHistorySize() ) |
注意:
- pin 全稱是 pointerIndex,表示第幾個手指,此處為了節省空間使用了縮寫。
- 歷史資料只有 ACTION_MOVE 事件。
- 歷史資料單點觸控和多點觸控均可以用。
下面是官方文件給出的一個簡單使用示例:
void printSamples(MotionEvent ev) {
final int historySize = ev.getHistorySize();
final int pointerCount = ev.getPointerCount();
for (int h = 0; h < historySize; h++) {
System.out.printf("At time %d:", ev.getHistoricalEventTime(h));
for (int p = 0; p < pointerCount; p++) {
System.out.printf(" pointer %d: (%f,%f)",
ev.getPointerId(p), ev.getHistoricalX(p, h), ev.getHistoricalY(p, h));
}
}
System.out.printf("At time %d:", ev.getEventTime());
for (int p = 0; p < pointerCount; p++) {
System.out.printf(" pointer %d: (%f,%f)",
ev.getPointerId(p), ev.getX(p), ev.getY(p));
}
}
獲取事件發生的時間
獲取事件發生的時間。
方法 | 簡介 |
---|---|
getDownTime() | 獲取手指按下時的時間。 |
getEventTime() | 獲取當前事件發生的時間。 |
getHistoricalEventTime(int pos) | 獲取歷史事件發生的時間。 |
- pos 表示歷史資料中的第幾個資料。( pos < getHistorySize() )
- 返回值型別為 long,單位是毫秒。
獲取壓力(接觸面積大小)
MotionEvent支援獲取某些輸入裝置(手指或觸控筆)的與螢幕的接觸面積和壓力大小,主要有以下方法:
描述中使用了手指,觸控筆也是一樣的。
方法 | 簡介 |
---|---|
getSize () | 獲取第1個手指與螢幕接觸面積的大小 |
getSize (int pin) | 獲取第pin個手指與螢幕接觸面積的大小 |
getHistoricalSize (int pos) | 獲取歷史資料中第1個手指在第pos次事件中的接觸面積 |
getHistoricalSize (int pin, int pos) | 獲取歷史資料中第pin個手指在第pos次事件中的接觸面積 |
getPressure () | 獲取第一個手指的壓力大小 |
getPressure (int pin) | 獲取第pin個手指的壓力大小 |
getHistoricalPressure (int pos) | 獲取歷史資料中第1個手指在第pos次事件中的壓力大小 |
getHistoricalPressure (int pin, int pos) | 獲取歷史資料中第pin個手指在第pos次事件中的壓力大小 |
- pin 全稱是 pointerIndex,表示第幾個手指。(pin < getPointerCount() )
- pos 表示歷史資料中的第幾個資料。( pos < getHistorySize() )
注意:
1、獲取接觸面積大小和獲取壓力大小是需要硬體支援的。
2、非常不幸的是大部分裝置所使用的電容屏不支援壓力檢測,但能夠大致檢測出接觸面積。
3、大部分裝置的 getPressure()
是使用接觸面積來模擬的。
4、由於某些未知的原因(可能系統版本和硬體問題),某些裝置不支援該方法。
我用不同的裝置對這兩個方法進行了測試,然而不同裝置測試出來的結果不相同,之後經過我多方查證,發現是系統問題,有的裝置上只有 getSize()
能用,有的裝置上只有 getPressure()
能用,而有的則兩個都不能用。
由於獲取接觸面積和獲取壓力大小受系統和硬體影響,使用的時候一定要進行資料檢測,以防因為裝置問題而導致程式出錯。
滑鼠事件
由於觸控筆事件和手指事件處理流程大致相同,所以就不講解了,這裡講解一下與滑鼠相關的幾個事件:
事件 | 簡介 |
---|---|
ACTION_HOVER_ENTER | 指標移入到視窗或者View區域,但沒有按下。 |
ACTION_HOVER_MOVE | 指標在視窗或者View區域移動,但沒有按下。 |
ACTION_HOVER_EXIT | 指標移出到視窗或者View區域,但沒有按下。 |
ACTION_SCROLL | 滾輪滾動,可以觸發水平滾動(AXIS_HSCROLL)或者垂直滾動(AXIS_VSCROLL) |
注意:
1、這些事件型別是 安卓4.0 (API 14) 才新增的。
2、使用 ` getActionMasked()` 獲得這些事件型別。
3、這些事件不會傳遞到 onTouchEvent(MotionEvent) 而是傳遞到 onGenericMotionEvent(MotionEvent) 。
輸入裝置型別判斷
輸入裝置型別判斷也是安卓4.0 (API 14) 才新增的,主要包括以下幾種裝置:
裝置型別 | 簡介 |
---|---|
TOOL_TYPE_ERASER | 橡皮擦 |
TOOL_TYPE_FINGER | 手指 |
TOOL_TYPE_MOUSE | 滑鼠 |
TOOL_TYPE_STYLUS | 手寫筆 |
TOOL_TYPE_UNKNOWN | 未知型別 |
使用 getToolType(int pointerIndex)
來獲取對應的輸入裝置型別,pointIndex可以為0,但必須小於 getPointerCount()
。
總結
雖然本文標題是 MotionEvent 詳解,但由於 MotionEvent 實在太龐大了,本文只能涉及一些比較常用的內容,某些不太常用的內容就在以後用到的時候再詳細介紹吧,像遊戲手柄等輸入裝置由於我暫時不做遊戲開發,也沒有過多瞭解,所以就不介紹給大家啦。
由於個人水平有限,文章中可能會出現錯誤,如果你覺得哪一部分有錯誤,或者發現了錯別字等內容,歡迎在評論區告訴我,另外,據說關注 作者微博 不僅能第一時間收到新文章訊息,還能變帥哦。
參考資料
MotionEvent
Android MotionEvent詳解