1. 程式人生 > >安卓自定義View進階-MotionEvent詳解

安卓自定義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 軸座標。

關於 getgetRaw 的區別可以參考這一篇文章 安卓自定義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_CANCELACTION_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 有非主要的手指擡起(即擡起之後仍然有手指在螢幕上)。
以下事件型別不推薦使用 ------------------
ACTION_POINTER_1_DOWN 第 2 個手指按下,已廢棄,不推薦使用。
ACTION_POINTER_2_DOWN 第 3 個手指按下,已廢棄,不推薦使用。
ACTION_POINTER_3_DOWN 第 4 個手指按下,已廢棄,不推薦使用。
ACTION_POINTER_1_UP 第 2 個手指擡起,已廢棄,不推薦使用。
ACTION_POINTER_2_UP 第 3 個手指擡起,已廢棄,不推薦使用。
ACTION_POINTER_3_UP 第 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() )

注意:

  1. pin 全稱是 pointerIndex,表示第幾個手指,此處為了節省空間使用了縮寫。
  2. 歷史資料只有 ACTION_MOVE 事件。
  3. 歷史資料單點觸控和多點觸控均可以用。

下面是官方文件給出的一個簡單使用示例:

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) 獲取歷史事件發生的時間。
  1. pos 表示歷史資料中的第幾個資料。( pos < getHistorySize() )
  2. 返回值型別為 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次事件中的壓力大小
  1. pin 全稱是 pointerIndex,表示第幾個手指。(pin < getPointerCount() )
  2. 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詳解

About Me

作者微博: @GcsSloop