Android MotionEvent 詳解,之前用了兩篇文章 事件分發(fā)機制原理 和 事件分發(fā)機制詳解來講解事件分發(fā),而作為事件分發(fā)主角之一的 MotionEvent 并沒有過多的說明,本文就帶大家了解 MotionEvent 的相關(guān)內(nèi)容,簡要介紹觸摸事件,主要包括 單點觸控、多點觸控、鼠標(biāo)事件 以及 getAction() 和 getActionMasked() 的區(qū)別。
Android 將所有的輸入事件都放在了 MotionEvent 中,隨著安卓的不斷發(fā)展壯大,MotionEvent 也開始變得越來越復(fù)雜,下面是我自己整理的 MotionEvent 大事記:

以上僅僅是簡要的說明幾次比較大的變動,細(xì)小的修復(fù)和更新不計其數(shù),此處就不一一列出了,反正也沒人關(guān)心這些東西。
MotionEvent 負(fù)責(zé)集中處理所有類型設(shè)備的輸入事件,但是由于某些設(shè)備使用的幾率較小本文會忽略講解,或者簡要講解,例如:
- 軌跡球只出現(xiàn)在最早的設(shè)備上,現(xiàn)代的設(shè)備上已經(jīng)見不到了,本文不再敘述。
- 觸控筆和手指處理流程基本相同,不再多說。
- 鼠標(biāo)在手機上使用概率也比較小,會在文末簡要介紹。
單點觸控
單點觸控就非常簡單啦,入門的工程師都會用,上一篇文章也簡要介紹過,主要涉及以下幾個事件:

和以下的幾個方法:

關(guān)于 get和 getRaw的區(qū)別可以參考這一篇文章
安卓自定義View基礎(chǔ)-坐標(biāo)系
單點觸控一次簡單的交互流程是這樣的:
手指落下(ACTION_DOWN) -> 多次移動(ACTION_MOVE) -> 離開(ACTION_UP)
- 本次事例中 ACTION_MOVE 有多次觸發(fā)。
- 如果僅僅是單擊(手指按下再抬起),不會觸發(fā) 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:
// 超出區(qū)域
break;
}
return super.onTouchEvent(event);
}
相信小伙伴對此已經(jīng)非常熟悉了,經(jīng)常使用的東西,我也不啰嗦了。
但其中有兩個比較特殊的事件: ACTION_CANCEL 和 ACTION_OUTSIDE 。
為什么說特殊呢,因為它們是由程序觸發(fā)而產(chǎn)生的,而且觸發(fā)條件也非常特殊,通常情況下即便不處理這兩個事件也沒有什么問題。接下來我們就扒一扒它們的真面目:
ACTION_CANCEL
ACTION_CANCEL的觸發(fā)條件是事件被上層攔截,然而我們在 事件分發(fā)機制原理 一文中了解到當(dāng)事件被上層 View 攔截的時候,ChildView 是收不到任何事件的,ChildView 收不到任何事件,自然也不會收到 ACTION_CANCE了,所以說這個 ACTION_CANCEL的正確觸發(fā)條件并不是這樣,那么是什么呢?
事實上,只有上層 View 回收事件處理權(quán)的時候,ChildView 才會收到一個 ACTION_CANCEL 事件。
這樣說可能不太容易理解,咱舉個例子?

例如:上層 View 是一個 RecyclerView,它收到了一個 ACTION_DOWN 事件,由于這個可能是點擊事件,所以它先傳遞給對應(yīng) ItemView,詢問 ItemView 是否需要這個事件,然而接下來又傳遞過來了一個 ACTION_MOVE 事件,且移動的方向和 RecyclerView 的可滑動方向一致,所以 RecyclerView 判斷這個事件是滾動事件,于是要收回事件處理權(quán),這時候?qū)?yīng)的 ItemView 會收到一個 ACTION_CANCEL ,并且不會再收到后續(xù)事件。
通俗一點?
RecyclerView:兒砸,這里有一個 ACTION_DOWN 你看你要不要。
ItemView :好嘞,我看看。
RecyclerView:噫?居然是移動事件ACTION_MOVE,我要滾起來了,兒砸,我可能要把你送去你姑父家(緩存區(qū))了,在這之前給你一個 ACTION_CANCEL,你要收好啊。
ItemView :……
這是實際開發(fā)中最有可能見到 ACTION_CANCEL 的場景了。
ACTION_OUTSIDE
ACTION_OUTSIDE的觸發(fā)條件更加奇葩,從字面上看,outside 意思不就是超出區(qū)域么?然而不論你如何滑動超出控件區(qū)域都不會觸發(fā) ACTION_OUTSIDE 這個事件。相信很多魔法師都對此很是疑惑,說好的超出區(qū)域呢?
實際上這個事件根本就不是在這里用的,看官方解釋(裝一下逼):
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.
一個觸摸事件已經(jīng)發(fā)生了UI元素的正常范圍之外。因此不再提供完整的手勢,只提供 運動/觸摸 的初始位置。
我們知道,正常情況下,如果初始點擊位置在該視圖區(qū)域之外,該視圖根本不可能會收到事件,然而,萬事萬物都不是絕對的,肯定還有一些特殊情況,你可曾還記得點擊 Dialog 區(qū)域外關(guān)閉嗎?Dialog 就是一個特殊的視圖(沒有占滿屏幕大小的窗口),能夠接收到視圖區(qū)域外的事件(雖然在通常情況下你根本用不到這個事件),除了 Dialog 之外,你最可能看到這個事件的場景是懸浮窗,當(dāng)然啦,想要接收到視圖之外的事件需要一些特殊的設(shè)置。
設(shè)置視圖的 WindowManager 布局參數(shù)的 flags為FLAG_WATCH_OUTSIDE_TOUCH
,這樣點擊事件發(fā)生在這個視圖之外時,該視圖就可以接收到一個 ACTION_OUTSIDE
事件。
參見StackOverflow:How to dismiss the dialog with click on outside of the dialog?
由于這個事件用到的幾率比較小,此處就不展開敘述了,以后用到的時候再詳細(xì)講解。
多點觸控
Android 在 2.0 版本的時候開始支持多點觸控,一旦出現(xiàn)了多點觸控,很多東西就突然之間變得麻煩起來了,首先要解決的問題就是 多個手指同時按在屏幕上,會產(chǎn)生很多的事件,這些事件該如何區(qū)分呢?
為了區(qū)分這些事件,工程師們用了一個很簡單的辦法--編號,當(dāng)手指第一次按下時產(chǎn)生一個唯一的號碼,手指抬起或者事件被攔截就回收編號,就這么簡單。
第一次按下的手指特殊處理作為主指針,之后按下的手指作為輔助指針,然后隨之衍生出來了以下事件(注意增加的事件和事件簡介的變化):

和以下方法:

由于多點觸控部分涉及內(nèi)容比較多,也很復(fù)雜,我準(zhǔn)備單獨用一篇文章進(jìn)行詳細(xì)敘述,所以這里只敘述一些基礎(chǔ)的內(nèi)容作為鋪墊:
getAction() 與 getActionMasked()
當(dāng)多個手指在屏幕上按下的時候,會產(chǎn)生大量的事件,如何在獲取事件類型的同時區(qū)分這些事件就是一個大問題了。
一般來說我們可以通過為事件添加一個int類型的index屬性來區(qū)分,但是我們知道谷歌工程師是有潔癖的(在 自定義View分類與流程 的onMeasure中已經(jīng)見識過了),為了添加一個通常數(shù)值不會超過10的index屬性就浪費一個int大小的空間簡直是不能忍受的,于是工程師們將這個index屬性和事件類型直接合并了。
int類型共32位(0x00000000),他們用最低8位(0x000000ff)表示事件類型,再往前的8位(0x0000ff00)表示事件編號,以手指按下為例講解數(shù)值是如何合成的:
ACTION_DOWN 的默認(rèn)數(shù)值為 (0x00000000)
ACTION_POINTER_DOWN 的默認(rèn)數(shù)值為 (0x00000005)

注意:
上面表格中用粗體標(biāo)示出的數(shù)值,可以看到隨著按下手指數(shù)量的增加,這個數(shù)值也是一直變化的,進(jìn)而導(dǎo)致我們使用 getAction() 獲取到的數(shù)值無法與標(biāo)準(zhǔn)的事件類型進(jìn)行對比,為了解決這個問題,他們創(chuàng)建了一個 getActionMasked() 方法,這個方法可以清除index數(shù)值,讓其變成一個標(biāo)準(zhǔn)的事件類型。
- 多點觸控時必須使用 getActionMasked() 來獲取事件類型。
- 單點觸控時由于事件數(shù)值不變,使用 getAction() 和 getActionMasked() 兩個方法都可以。
- 使用 getActionIndex() 可以獲取到這個index數(shù)值。不過請注意,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 字面意思知道,這個只表示事件的序號,而且根據(jù)其說明文檔解釋,這個 ActionIndex 只有在手指按下(down)和抬起(up)時是有用的,在移動(move)時是沒有用的,事件追蹤非常重要的一環(huán)就是移動(move),然而它卻沒卵用,這也太不實在了 ( ̄Д ̄)?
鄭重聲明:追蹤事件流,請認(rèn)準(zhǔn) PointId,這是唯一官方指定標(biāo)準(zhǔn),不要相信 ActionIndex 那個小婊砸。
PointId 在手指按下時產(chǎn)生,手指抬起或者事件被取消后消失,是一個事件流程中唯一不變的標(biāo)識,可以在手指按下時 通過 getPointerId(int pointerIndex) 獲得。 (參數(shù)中的 pointerIndex 就是 actionIndex)
關(guān)于事件流的追蹤等問題在講解多點觸控時再詳細(xì)講解。
歷史數(shù)據(jù)(批處理)
由于我們的設(shè)備非常靈敏,手指稍微移動一下就會產(chǎn)生一個移動事件,所以移動事件會產(chǎn)生的特別頻繁,為了提高效率,系統(tǒng)會將近期的多個移動事件(move)按照事件發(fā)生的順序進(jìn)行排序打包放在同一個 MotionEvent 中,與之對應(yīng)的產(chǎn)生了以下方法:

注意:
pin 全稱是 pointerIndex,表示第幾個手指,此處為了節(jié)省空間使用了縮寫。
- 歷史數(shù)據(jù)只有 ACTION_MOVE 事件。
- 歷史數(shù)據(jù)單點觸控和多點觸控均可以用。
- 下面是官方文檔給出的一個簡單使用示例:
下面是官方文檔給出的一個簡單使用示例:
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));
}
}
獲取事件發(fā)生的時間
獲取事件發(fā)生的時間。

- pos 表示歷史數(shù)據(jù)中的第幾個數(shù)據(jù)。( pos < getHistorySize() )
- 返回值類型為 long,單位是毫秒。
獲取壓力(接觸面積大小)
MotionEvent支持獲取某些輸入設(shè)備(手指或觸控筆)的與屏幕的接觸面積和壓力大小,主要有以下方法:
描述中使用了手指,觸控筆也是一樣的。

- pin 全稱是 pointerIndex,表示第幾個手指。(pin < getPointerCount() )
- pos 表示歷史數(shù)據(jù)中的第幾個數(shù)據(jù)。( pos < getHistorySize() )
注意:
1、獲取接觸面積大小和獲取壓力大小是需要硬件支持的。
2、非常不幸的是大部分設(shè)備所使用的電容屏不支持壓力檢測,但能夠大致檢測出接觸面積。
3、大部分設(shè)備的 getPressure() 是使用接觸面積來模擬的。
4、由于某些未知的原因(可能系統(tǒng)版本和硬件問題),某些設(shè)備不支持該方法。
我用不同的設(shè)備對這兩個方法進(jìn)行了測試,然而不同設(shè)備測試出來的結(jié)果不相同,之后經(jīng)過我多方查證,發(fā)現(xiàn)是系統(tǒng)問題,有的設(shè)備上只有 getSize() 能用,有的設(shè)備上只有 getPressure() 能用,而有的則兩個都不能用。
由于獲取接觸面積和獲取壓力大小受系統(tǒng)和硬件影響,使用的時候一定要進(jìn)行數(shù)據(jù)檢測,以防因為設(shè)備問題而導(dǎo)致程序出錯。
鼠標(biāo)事件
由于觸控筆事件和手指事件處理流程大致相同,所以就不講解了,這里講解一下與鼠標(biāo)相關(guān)的幾個事件:

注意:
1、這些事件類型是 安卓4.0 (API 14) 才添加的。2、使用
getActionMasked() 獲得這些事件類型。3、這些事件不會傳遞到 onTouchEvent(MotionEvent) 而是傳遞到 onGenericMotionEvent(MotionEvent) 。
輸入設(shè)備類型判斷
輸入設(shè)備類型判斷也是安卓4.0 (API 14) 才添加的,主要包括以下幾種設(shè)備:

使用 getToolType(int pointerIndex) 來獲取對應(yīng)的輸入設(shè)備類型,pointIndex可以為0,但必須小于 getPointerCount()。