安卓自定義View進階-多點觸控詳解
Android 多點觸控詳解,在前面的幾篇文章中我們大致瞭解了 Android 中的事件處理流程和一些簡單的處理方案,本次帶大家瞭解 Android 多點觸控相關的一些知識。
多點觸控 ( Multitouch,也稱 Multi-touch ),即同時接受螢幕上多個點的人機互動操作,多點觸控是從 Android 2.0 開始引入的功能,在 Android 2.2 時對這一部分進行了重新設計。
在本文開始之前,先回顧一下 MotionEvent詳解 中提到過的內容:
- Android 將所有的事件都封裝進了
Motionvent
中。 - 我們可以通過複寫
onTouchEvent
OnTouchListener
來獲取 View 的事件。 - 多點觸控獲取事件型別請使用
getActionMasked()
。 - 追蹤事件流請使用
PointId
。
多點觸控相關的事件:
事件 | 簡介 |
---|---|
ACTION_DOWN | 第一個 手指 初次接觸到螢幕 時觸發。 |
ACTION_MOVE | 手指 在螢幕上滑動 時觸發,會多次觸發。 |
ACTION_UP | 最後一個 |
ACTION_POINTER_DOWN | 有非主要的手指按下(即按下之前已經有手指在螢幕上)。 |
ACTION_POINTER_UP | 有非主要的手指擡起(即擡起之後仍然有手指在螢幕上)。 |
以下事件型別不推薦使用 | ---以下事件在 2.2 版本以上被標記為廢棄--- |
第 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座標 |
回顧完畢,開始正文。
一、多點觸控相關問題
在引入多點觸控之前,事件的型別很少,基本事件型別只有按下(down)、移動(move) 和 擡起(up),即便加上那些特殊的事件型別也只有幾種而已,所以我們可以用幾個常量來標記這些事件,在使用的時候使用 getAction()
方法來獲取具體的事件,之後和這些常量進行對比就行了。
在 Android 2.0 版本的時候,開始引入多點觸控技術,由於技術上並不成熟,硬體和驅動也跟不上,多數裝置只能支援追蹤兩三個點而已,因此在設計 API 上採取了一種簡單粗暴的方案,添加了幾個常量用於多點觸控的事件型別的判斷。
事件 | 簡介 |
---|---|
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 個手指擡起,已廢棄,不推薦使用。 |
這些事件型別是用來判斷非主要手指(第一個按下的稱為主要手指)的按下和擡起,使用起來大概是這樣子:
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: break;
case MotionEvent.ACTION_UP: break;
case MotionEvent.ACTION_MOVE: break;
case MotionEvent.ACTION_POINTER_1_DOWN: break;
case MotionEvent.ACTION_POINTER_2_DOWN: break;
case MotionEvent.ACTION_POINTER_3_DOWN: break;
case MotionEvent.ACTION_POINTER_1_UP: break;
case MotionEvent.ACTION_POINTER_2_UP: break;
case MotionEvent.ACTION_POINTER_3_UP: break;
}
看到這裡可能會產生以下的一些疑問?
1.為什麼沒有 ACTION_POINTER_X_MOVE ?
在多指觸控中所有的移動事件都是使用 ACTION_MOVE
, 並沒有追蹤某一個手指的 move 事件型別,個人猜測主要是因為:很難無歧義的實現單獨追蹤每一個手指。
要理解這個,首先要明白裝置是如何識別多點觸控的,裝置沒有眼睛,不能像我們人一樣看到有幾個手指(或者觸控筆)在螢幕上。
目前大多數 Android 裝置都是電容屏,它們感知觸控是利用手指(觸控筆)與螢幕接觸產生的微小電流變化,之後通過計算這些電流變化來得出具體的觸控位置,在多點觸控中,當兩個觸控點足夠靠近時,裝置實際上是無法分清這兩個點的。因此當兩個觸控點靠近(重合)後再分開,裝置很可能就無法正確的追蹤兩個點了,所以也很難實現無歧義的追蹤每一個點。
並且從軟體上來說,事件的編號產生和複用也是一個大問題,例如下面的場景:
事件 | 手指數量 | 編號變化 |
---|---|---|
一個手指按下(命名為A) | 1 | A手指的編號為0,id為0 |
一個手指按下(命名為B) | 2 | B手指的編號為1,id為1 |
A手指擡起 | 1 | B手指編號變更為0,id不變為1 |
一個手指按下(命名為C) | 2 | C手指編號為0,id為0,B手指編號為1,id為1 |
注意觀察上面編號和id的變化,有兩個問題,1、B手指的編號變化了。2、A手指和C手指id是相同的(A手指擡起後,C手指按下替代了A手指)。所以這就引出了一個問題:如果存在 ACTION_POINTER_X_MOVE,那麼X應該用什麼標誌呢?編號會變化,id雖然不會變化,但id會被複用,例如A手指擡起後C手指按下,C手指複用了A手指的id。所以不論使用哪一個都不能保證唯一性。
當然了,解決問題最好的方式就是把問題丟擲去,既然從硬體和軟體上都不能保證唯一性和不變性,就不做區分了,因此所有的 move 事件都是 ACTION_MOVE
, 具體是哪個手指產生的 move 使用者可以結合其他事件(按下和擡起)來綜合判斷。
2.超過4個手指怎麼辦?
2.0 相容版,在2.2 之前的設計中,其提供的常量最多能判斷四個手指的擡起和落下,當超過四個手指時怎麼辦呢?
由於在 2.2 版本之前,由於沒有 getActionMasked
方法,我們可以自己自己手動進行計算,例如下面這樣 :
String TAG = "Gcs";
int action = event.getAction() & MotionEvent.ACTION_MASK;
int index = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
>> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
switch (action) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG,"第1個手指按下");
break;
case MotionEvent.ACTION_UP:
Log.e(TAG,"最後1個手指擡起");
break;
case MotionEvent.ACTION_POINTER_1_DOWN: // 此時相當於 ACTION_POINTER_DOWN
Log.e(TAG,"第"+(index+1)+"個手指按下");
break;
case MotionEvent.ACTION_POINTER_1_UP: // 此時相當於 ACTION_POINTER_UP
Log.e(TAG,"第"+(index+1)+"個手指擡起");
break;
}
在上面的例子中有幾點比較關鍵:
2.1、action 與 Index 的獲得
我們在 MotionEvent詳解 中瞭解過,Android中的事件一般用最後8位來表示事件型別,再往前8位來表示Index。
例如多指觸控的按下事件,其事件型別是 0x00000005, 其Index標誌位是 0x00000005,隨著更多的手指按下,其中變化的部分是 Index 標誌位,最後兩位是始終不變的,所以我們只要能將這兩個分離開就行了。
取得事件型別(action)
// 獲取事件型別
int action = event.getAction() & MotionEvent.ACTION_MASK;
這個非常簡單,ACTION_MASK=0x000000ff, 與 getAction() 進行按位與操作後保留最後8位內容(十六進位制每一個字元轉化為二進位制是4位)。
例如:
0x00000105 & 0x000000ff = 0x00000005
取得事件索引(index)
// 獲取index編號
int index = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
>> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
ACTION_POINTER_INDEX_MASK = 0x0000ff00
ACTION_POINTER_INDEX_SHIFT = 8
首先讓 getAction() 與 ACTION_POINTER_INDEX_MASK 按位與之後,只保留 Index 那8位,之後再右移8位,最終就拿到了 Index 的真實數值。
例如:
0x00000105 & 0x0000ff00 = 0x00000100
0x00000100 » 8 = 0x00000001
2.2、用 ACTION_POINTER_1_DOWN 代替 ACTION_POINTER_DOWN
這是因為在 2.0 版本的時候還沒有 ACTION_POINTER_DOWN 的這個常量,但是它們兩個點數值是相同的,都是 0x00000005,這個你可以檢視官方文件或者原始碼,甚至你直接寫 case 0x00000005
也行,擡起也是同理。
2.3、只考慮相容 2.2 以上的版本
當然了,如果你不需要相容 2.0 版本,只需要相容到 2.2 以上的話就很簡單了,像下面這樣:
String TAG = "Gcs";
int index = event.getActionIndex();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
Log.e(TAG,"第1個手指按下");
break;
case MotionEvent.ACTION_UP:
Log.e(TAG,"最後1個手指擡起");
break;
case MotionEvent.ACTION_POINTER_DOWN:
Log.e(TAG,"第"+(index+1)+"個手指按下");
break;
case MotionEvent.ACTION_POINTER_UP:
Log.e(TAG,"第"+(index+1)+"個手指擡起");
break;
}
3. index 和 pointId 的變化規則
在 2.2 版本以上,我們可以通過 getActionIndex() 輕鬆獲取到事件的索引(Index),但是這個事件索引的變化還是有點意思的,Index 變化有以下幾個特點:
1、從 0 開始,自動增長。
2、如果之前落下的手指擡起,後面手指的 Index 會隨之減小。
3、Index 變化趨向於第一次落下的數值(落下手指時,前面有空缺會優先填補空缺)。
4、對 move 事件無效。
下面我們逐條解釋一下具體含義。
3.1、從 0 開始,自動增長。
這一條非常簡單,也很容易理解,而且在 MotionEvent詳解 中講解 getAction() 與 getActionMasked() 也簡單說過。
手指按下 | 觸發事件(數值) |
---|---|
第1個手指按下 | ACTION_DOWN (0x00000000) |
第2個手指按下 | ACTION_POINTER_DOWN (0x00000105) |
第3個手指按下 | ACTION_POINTER_DOWN (0x00000205) |
第4個手指按下 | ACTION_POINTER_DOWN (0x00000305) |
注意加粗的位置,數值隨著手指按下而不斷變大。
3.2、如果之前落下的手指擡起,後面手指的 Index 會隨之減小。
這個也比較容易理解,像下面這樣:
手指按下 | 觸發事件(數值) |
---|---|
第1個手指按下 | ACTION_DOWN (0x00000000) |
第2個手指按下 | ACTION_POINTER_DOWN (0x00000105) |
第3個手指按下 | ACTION_POINTER_DOWN (0x00000205) |
第2個手指擡起 | ACTION_POINTER_UP (0x00000106) |
第3個手指擡起 | ACTION_POINTER_UP (0x00000106) |
注意最後兩次觸發的事件,它的 Index 都是 1,這樣也比較容易解釋,當原本的第 2 個手指擡起後,螢幕上就只剩下兩個手指了,之前的第 3 個手指就變成了第 2 個,於是擡起時觸發事件的 Index 為 1,即之前落下的手指擡起,後面手指的 Index 會隨之減小。
3.3、Index 變化趨向於第一次落下的數值(落下手指時,前面有空缺會優先填補空缺)。
這個就有點神奇了,通過上一條規則,我們知道,某一個手指的 Index 可能會隨著其他手指的擡起而變小,這次我們用 4 個手指測試一下 Index 的變化趨勢。
手指按下 | 觸發事件(數值) |
---|---|
第1個手指按下 | ACTION_DOWN (0x00000000) |
第2個手指按下 | ACTION_POINTER_DOWN (0x00000105) |
第3個手指按下 | ACTION_POINTER_DOWN (0x00000205) |
第2個手指擡起 | ACTION_POINTER_UP (0x00000106) |
第4個手指按下 | ACTION_POINTER_DOWN (0x00000105) |
第3個手指擡起 | ACTION_POINTER_UP (0x00000206) |
這個要和上一個對比這看,重點觀察第 3 個手指所觸發事件區別,在上一個示例中,隨著第 2 個手指的擡起,第 3 個手指變化為第 2(01) 個,所以擡起時觸發的是第 2 根手指的擡起事件(刪除線部分)。
但是,如果第 2 個手指擡起後,落在螢幕上另外一個手指會怎樣?經過測試,發現另外落下的手指會替代之前第 2 個手指的位置,系統判定為 2(01),而不是順延下去變成 3(02),並且原本第3個手指的index變為原來數值(02),但是如果繼續落下其他的手指,數值則會順延。
即手指擡起時的 Index 會趨向於和按下時相同,雖然在手指數量不足時,Index 會變小,但是當手指變多時,Index 會趨向於保持和按下時一樣。
PS:由於程式是從0開始計數的,所以 0 就是 1, 1 就是 2 …
3.4、對 move 事件無效。
這個也比較容易理解,我們所取得的 Index 屬性實際上是從事件上分離下來的,但是 move 事件始終為 0x00000002,也就是說,在 move 時不論你移動哪個手指,使用 getActionIndex()
獲取到的始終是數值 0。
既然 move 事件無法用事件索引(Index)區別,那麼該如何區分 move 是那個手指發出的呢?這就要用到 pointId 了,pointId 和 index 最大的區別就是 pointId 是不變的,始終為第一次落下時生成的數值,不會受到其他手指擡起和落下的影響。
3.5、pointId 與 index 的異同。
相同點 | 不同點 |
---|---|
1. 從 0 開始,自動增長。 2. 落下手指時優先填補空缺(填補之前擡起手指的編號)。 |
1. Index 會變化,pointId 始終不變。 |
4. Move 相關事件
4.1 actionIndex 與 pointerIndex
在 move 中無法取得 actionIndex 的,我們需要使用 pointerIndex 來獲取更多的資訊,例如某個手指的座標:
getX(int pointerIndex)
getY(int pointerIndex)
但是這個 pointerIndex 又是什麼呢?和 actionIndex 有區別麼?
實際上這個 pointerIndex 和 actionIndex 區別並不大,兩者的數值是相同的,你可以認為 pointerIndex 是特地為 move 事件準備的 actionIndex。
4.2 pointerIndex 與 pointerId
型別 | 簡介 |
---|---|
pointerIndex | 用於獲取具體事件,可能會隨著其他手指的擡起和落下而變化 |
pointerId | 用於識別手指,手指按下時產生,手指擡起時回收,期間始終不變 |
這兩個數值使用以下兩個方法相互轉換。
方法 | 簡介 |
---|---|
getPointerId(int pointerIndex) | 獲取一個指標(手指)的唯一識別符號ID,在手指按下和擡起之間ID始終不變。 |
findPointerIndex(int pointerId) | 通過 pointerId 獲取到當前狀態下 pointIndex,之後通過 pointIndex 獲取其他內容。 |
通常情況下,pointerIndex 和 pointerId 是相同的,但也可能會因為某些手指的擡起而變得不同。
4.3 遍歷多點觸控
先來一個簡單的,遍歷出多個手指的 move 事件:
String TAG = "Gcs";
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
for (int i = 0; i < event.getPointerCount(); i++) {
Log.i("TAG", "pointerIndex="+i+", pointerId="+event.getPointerId(i));
// TODO
}
}
通過遍歷 pointerCount 獲取到所有的 pointerIndex,同時通過 pointerIndex 來獲取 pointerId,可以通過不同手指擡起和按下後移動來觀察 pointerIndex 和 pointerId 的變化。
4.4 在多點觸控中追蹤單個手指
要實現追蹤單個手指還是有些麻煩的,需要同時使用上 actionIndex, pointerId 和 pointerIndex,例如,我們只追蹤第2個手指,並畫出其位置:
/**
* 繪製出第二個手指第位置
*/
public class MultiTouchTest extends CustomView {
String TAG = "Gcs";
// 用於判斷第2個手指是否存在
boolean haveSecondPoint = false;
// 記錄第2個手指第位置
PointF point = new PointF(0, 0);
public MultiTouchTest(Context context) {
this(context, null);
}
public MultiTouchTest(Context context, AttributeSet attrs) {
super(context, attrs);
mDeafultPaint.setAntiAlias(true);
mDeafultPaint.setTextAlign(Paint.Align.CENTER);
mDeafultPaint.setTextSize(30);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int index = event.getActionIndex();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_POINTER_DOWN:
// 判斷是否是第2個手指按下
if (event.getPointerId(index) == 1){
haveSecondPoint = true;
point.set(event.getY(), event.getX());
}
break;
case MotionEvent.ACTION_POINTER_UP:
// 判斷擡起的手指是否是第2個
if (event.getPointerId(index) == 1){
haveSecondPoint = false;
point.set(0, 0);
}
break;
case MotionEvent.ACTION_MOVE:
if (haveSecondPoint) {
// 通過 pointerId 來獲取 pointerIndex
int pointerIndex = event.findPointerIndex(1);
// 通過 pointerIndex 來取出對應的座標
point.set(event.getX(pointerIndex), event.getY(pointerIndex));
}
break;
}
invalidate(); // 重新整理
return true;
}
@Override
protected void onDraw(Canvas canvas) {
canvas.save();
canvas.translate(mViewWidth/2, mViewHeight/2);
canvas.drawText("追蹤第2個按下手指的位置", 0, 0, mDeafultPaint);
canvas.restore();
// 如果螢幕上有第2個手指則繪製出來其位置
if (haveSecondPoint) {
canvas.drawCircle(point.x, point.y, 50, mDeafultPaint);
}
}
}
這段程式碼也非常短,其核心就是通過判斷數值為 1 的 pointerId 是否存在,如果存在就在 move 的時候取出其座標,並繪製出來。
雖然邏輯簡單,但個人感覺寫起來還是有些麻煩,如果有更簡單的方案歡迎告訴我。
二、如何使用多點觸控
多點觸控應用還是比較廣泛的,至少目前大部分的圖片檢視都需要用到多點觸控技術(用於拖動和縮放圖片)。
但是在某些看似不需要多觸控的地方也需要對多點觸控進行判斷,只要是多點觸控可能引起錯誤的地方都應該加上多點觸控的判斷。例如使用到 move 事件的時候,由於 move 事件可能由多個手指同時觸發,所以可能會出現同時被多個手指控制的情況,如果不適當的處理,這個 move 就可能由任何一個手指觸發。
舉一個簡單的例子:
如果我們需要一個可以用單指拖動的圖片。假如我們不進行多指觸控的判斷,像下面這樣:
沒有針對多指觸控處理版本:
/**
* 一個可以拖圖片動的 View
*/
public class DragView1 extends CustomView {
String TAG = "Gcs";
Bitmap mBitmap; // 圖片
RectF mBitmapRectF; // 圖片所在區域
Matrix mBitmapMatrix; // 控制圖片的 matrix
boolean canDrag = false;
PointF lastPoint = new PointF(0, 0);
public DragView1(Context context) {
this(context, null);
}
public DragView1(Context context, AttributeSet attrs) {
super(context, attrs);
// 調整圖片大小
BitmapFactory.Options options = new BitmapFactory.Options();
options.outWidth = 960/2;
options.outHeight = 800/2;
mBitmap = BitmapFactory.decodeResource(this.getResources(), R.drawable.drag_test, options);
mBitmapRectF = new RectF(0,0,mBitmap.getWidth(), mBitmap.getHeight());
mBitmapMatrix = new Matrix();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// 判斷按下位置是否包含在圖片區域內
if (mBitmapRectF.contains((int)event.getX(), (int)event.getY())){
canDrag = true;
lastPoint.set(event.getX(), event.getY());
}
break;
case MotionEvent.ACTION_UP:
canDrag = false;
case MotionEvent.ACTION_MOVE:
if (canDrag) {
// 移動圖片
mBitmapMatrix.postTranslate(event.getX() - lastPoint.x, event.getY() - lastPoint.y);
// 更新上一次點位置
lastPoint.set(event.getX(), event.getY());
// 更新圖片區域
mBitmapRectF = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
mBitmapMatrix.mapRect(mBitmapRectF);
invalidate();
}
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawBitmap(mBitmap, mBitmapMatrix, mDeafultPaint);
}
}
這個版本非常簡單,當然了,如果正常使用(只使用一個手指)的話也不會出問題,但是當使用多個手指,且有擡起和按下的時候就可能出問題,下面用一個典型的場景演示一下:
注意在第二個手指按下,第一個手指擡起時,此時原本的第二個手指會被識別為第一個,所以圖片會直接跳動到第二個手指位置。
為了不出現這種情況,我們可以判斷一下 pointId 並且只獲取第一個手指的資料,這樣就能避免這種情況發生了,如下。
針對多指觸控處理後版本:
/**
* 一個可以拖圖片動的 View
*/
public class DragView extends CustomView {
String TAG = "Gcs";
Bitmap mBitmap; // 圖片
<