Android自定義View系列:多點觸控原理

1 事件攔截機制

閱讀下面的多點觸控原理知識,需要了解一定的事件攔截機制原理,可以參考我編寫的文章:Android自定義View系列:事件攔截機制原理

2 getAction()和getActionMasked()的區別

現在網上還是有很多博客在 onTouchEvent() 處理觸摸反饋判斷時使用的 MotionEvent.getAction(),那麼 MotionEvent.getAction()MotionEvent.getActionMasked() 有什麼區別呢?爲什麼推薦我們使用 MotionEvent.getActionMasked()

MotionEvent.getAction() 是在早期Android版本就已經存在,在只有單點觸控的時候只包含事件信息,比如 MotionEvent.ACTION_DOWNMotionEvent.ACTION_UPMotionEvent.ACTION_MOVE;但在多點觸控時它就多了一個信息:還需要知道按下的時候你是第一個手指還是非第一個手指,擡起的時候是最後一個手指擡起了還是非最後一個手指擡起了。因爲多點觸控的處理邏輯和單點觸控不同,比如 MotionEvent.ACTION_POINTER_DOWNMotionEvent.ACTION_POINTER_UP ,所以就要區分兩種分別處理,Android因此在API 8提供了 MotionEvent.getActionMasked()MotionEvent.getActionIndex()。那麼 MotionEvent.getAction() 就要將兩個信息壓縮到32位的 int 類型裏面,而 MotionEvent.getActionMasked() 是拆分了信息只有事件信息。

因此在現在的單點觸控情況下使用 MotionEvent.getAction() 是可以正常使用的,但在多點觸控情況下,它沒有將多點觸控的信息拆分全部壓在32位 int 類型裏面,所以處理的信息就會是錯誤的。實際開發中,我們推薦使用 MotionEvent.getActionMasked()MotionEvent.getActionIndex() 來分別判斷回調的事件信息和獲取哪個手指處理事件。

單點觸控事件序列:

ACTION_DOWN P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_UP P(x, y)

多點觸控事件序列:

ACTION_DOWN:第一個手指按下(之前沒有任何手指觸摸到View)
ACTION_MOVE:有手指發生移動
ACTION_MOVE
ACTION_POINTER_DOWN:額外手指按下(按下之前已經有別的手指觸摸到View)
ACTION_MOVE
ACTION_MOVE
ACTION_POINTER_UP:有手指擡起,但不是最後一個(擡起之後,仍然還有別的手指在觸摸着View)
ACTION_MOVE
ACTION_UP:最後一個手指擡起(擡起之後沒有任何手指觸摸到View,這個手指未必是ACTION_DOWN的那個手指)

可以發現相比單點觸控,處理多點觸控時多了 ACTION_POINTER_DOWNACTION_POINTER_UP

2 多點觸控事件序列分析

在說明多點觸控的三種使用場景之前,有必要理解多點觸控事件序列在處理過程中與單點觸控的不同。

2.1 index和id

上面的多點觸控事件序列我們再將詳細信息追加上:

ACTION_DOWN P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_POINTER_DOWN P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id) P(x, y, index, id)
ACTION_POINTER_UP P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_UP P(x, y, index, id)

pointer多了一些數據:index和id。

  • index:當事件序列開始 ACTION_DOWN 的時候,該手指默認會分配一個索引 index=0,表示在多點觸控時該手指的索引,通過該索引我們可以在 onTouchEvent() 時通過 MotionEvent.getX(index)MotionEvent.getY(index) 獲取手指在View的座標

需要特別注意的是:index它並不是固定的,它根據事件序列的改變而改變,它總是有序的。

比如:

ACTION_DOWN P(x, y, 0, 0)
ACTION_MOVE P(x, y, 0, 0)
ACTION_MOVE P(x, y, 0, 0)
ACTION_POINTER_DOWN P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_POINTER_UP P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 1)
ACTION_UP P(x, y, 0, 1)

上面的事件序列假設有兩個手指,觸發 ACTION_DOWN 手指爲 index=0,id=0;在事件處理過程中 ACTION_POINTER_DOWN 被回調,說明另一個手指加入事件序列,手指爲 index=1, id=1;在事件處理過程中 ACTION_POINTER_UP 被回調,有手指鬆開,鬆開的手指是 index=0, id=0,所以在最後是手指 id=1 的手指繼續事件序列,並且它的index變爲0。

我們可以看下 getX()getY() 的源碼:

public final float getX() {
    return nativeGetAxisValue(mNativePtr, AXIS_X, 0, HISTORY_CURRENT);
}
public final float getY() {
     return nativeGetAxisValue(mNativePtr, AXIS_Y, 0, HISTORY_CURRENT);
 }

getX()getY() 的源碼其中 pointerIndex 的值默認爲0,就是上面舉例的index。

所以在多點觸控中,getX()getY() 是不可用的,它已經將 pointerIndex 寫死值爲0,所以無法準確獲取到需要手指的座標位置。需要更改爲使用 getX(pointerIndex)getY(pointerIndex) 來獲取:

public final float getX(int pointerIndex) {
    return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
}
public final float getY(int pointerIndex) {
    return nativeGetAxisValue(mNativePtr, AXIS_Y, pointerIndex, HISTORY_CURRENT);
}

既然index是隨時都在變化的,那麼你可能會疑問:那在多點觸控的時候,對應手指的索引要怎麼拿?我應該怎麼才能拿到觸摸View手指的座標?

答:id。

上面事件序列的pointer還有id,這個id在整個事件序列中它都是固定不變的,通過它我們就可以獲取手指當前的index,繼而通過index再調用 getX(index)getY(index) 獲取到座標點。

Android給我們提供了幾個API在多點觸控場景下使用:

  • MotionEvent.getX(pointerIndex):通過pointerIndex獲取觸摸View的某個手指的橫座標

  • MotionEvent.getY(pointerIndex):通過pointerIndex獲取觸摸View的某個手指的縱座標

  • MotionEvent.getPointerId(pointerIndex):通過pointerIndex獲取觸摸View的某個手指id

  • MotionEvent.findPointerIndex(id):通過id獲取觸摸View的某個手指當前的pointerIndex索引

  • MotionEvent.getActionIndex():在 MotionEvent.ACTION_POINTER_DOWN 獲取非第一個手指按下的手指索引,在 MotionEvent.ACTION_POINTER_UP 獲取非最後一個手指按下的索引。該API在多點觸控只適用於上面兩種常見回調使用

2.2 getActionIndex()

getActionIndex() 這個API比較特殊,它在多點觸控的場景只適用於 MotionEvent.ACTION_POINTER_DOWNMotionEvent.ACTION_POINTER_UP 使用,那爲什麼不能在 MotionEvent.ACTION_MOVE 獲取調用?

當多個手指觸摸的時候,其實多個手指是在同時移動的(手指的輕微移動都會回調 MotionEvent.ACTION_MOVE),我們上面說到pointer的索引index是會根據事件序列的改變而改變的,也就是說導致 MotionEvent.ACTION_MOVE 的手指索引是不斷切換的。所以在 MotionEvent.ACTION_MOVE 並不方便獲取正在導致那個事件的手指,也是沒有意義的,在 MotionEvent.ACTION_MOVE 調用 MotionEvent.getActionIndex() 總是返回0,該值沒有任何意義不代表哪個手指的索引

3 多點觸控的三種使用場景

3.1 接力型

接力型:同一時刻只有一個pointer起作用,即最新的pointer。典型:ListView、RecyclerView。

接力型的場景說明簡單來說就是:假設有兩個手指,一個手指在觸摸移動View的時候,另一個手指介入此次事件序列,然後其中一個手指鬆手,另一個手指接管了事件序列直到結束。

實現方式:在 ACTION_POINTER_DOWNACTION_POINTER_UP 時記錄下最新的pointer,在之後的 ACTION_MOVE 事件中使用這個pointer來判斷位置。

demo:多點觸控接力移動圖片

public class MultiTouchView extends View {
	private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Bitmap bitmap;
    private float downX;
    private float downY;
    private float originalOffsetX;
    private float originalOffsetY;
    private float offsetX;
    private float offsetY;
    private int trackingPointerId;

    public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
       super(context, attrs);
	   bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
   }

	 // 多指接力型(兩個手指滑動時,一個手指放開另外一個手指接手滑動)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN: // 第一根手指按下回調
                trackingPointerId = event.getPointerId(0); // 第一根手指index爲0
                updateOffset(event, 0);
                break;
            case MotionEvent.ACTION_MOVE:
                int index = event.findPointerIndex(trackingPointerId); // 根據id獲取索引
                offsetX = event.getX(index) - downX + originalOffsetX;
                offsetY = event.getY(index) - downY + originalOffsetY;
                invalidate();
                break;
            case MotionEvent.ACTION_POINTER_DOWN: // 非第一根手指按下回調
                // 有新手指加入事件序列,更新跟蹤的手指id
                int actionIndex = event.getActionIndex();
                trackingPointerId = event.getPointerId(actionIndex);

                // 更新爲新手指的座標,讓新手指接管事件
                updateOffset(event, actionIndex);
                break;
            case MotionEvent.ACTION_POINTER_UP: // 非第一個手指鬆手回調
                actionIndex = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex);
                // 鬆手的手指是當前正在跟蹤的手指
                if (pointerId == trackingPointerId) {
                    int newIndex;
                    // getPointerCount()此時還是包含鬆手的手指數算在內
                    if (actionIndex == event.getPointerCount() - 1) {
                        newIndex = event.getPointerCount() - 2;
                    } else {
                        newIndex = event.getPointerCount() - 1;
                    }
                    trackingPointerId = event.getPointerId(newIndex);
                    updateOffset(event, newIndex);
                }
                break;
        }
        return true;
    }

    private void updateOffset(MotionEvent event, int index) {
        downX = event.getX(index);
        downY = event.getY(index);
        originalOffsetX = offsetX;
        originalOffsetY = offsetY;
    }

	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmap, offsetX, offsetY, paint);
    }
}

3.2 配合型/協作型

配合型/協作型:所有觸摸到View的pointer共同起作用。典型:ScaleGestureDetector、GestureDetector的onScroll()判斷。

配合型/協作型的場景說明簡單來說就是:假設有多個手指觸摸View,選擇多個手指的中心位置作爲焦點座標處理View。

實現方式:在每個 MotionEvent.ACTION_DOWNMotionEvent.ACTION_POINTER_DOWNMotionEvent.ACTION_POINTER_UPMotionEvent.ACTION_UP 事件中使用所有pointer的座標來共同更新焦點座標,並在 MotionEvent.ACTION_MOVE 中使用所有pointer的座標來判斷位置。

demo:多個手指觸摸View協同移動圖片

public class MultiTouchView extends View {
	private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Bitmap bitmap;
    private float downX;
    private float downY;
    private float originalOffsetX;
    private float originalOffsetY;
    private float offsetX;
    private float offsetY;

	public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    	bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
    }    

	// 多指協作型(通過多個手指的橫縱座標計算出她們的中心點座標,以中心點座標作爲滑動座標)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float sumX = 0;
        float sumY = 0;
        // 多指滑動鬆開時,因爲ACTION_POINTER_UP此時還在進行中,所以獲取的event.getPointerCount()數量是不對的
        // 比如現在兩個手指在滑動bitmap,現在鬆開一個手指,event.getPointerCount()還是2
        // 如果不過濾ACTION_POINTER_UP鬆開的手指數量就會導致計算錯誤,出現bitmap位置跳一下到當前手指觸摸位置的問題
        boolean isPointerUp = event.getActionMasked() == MotionEvent.ACTION_POINTER_UP;
        for (int i = 0; i < event.getPointerCount(); i++) {
            // 不是擡起事件,計算沒有擡起的手指的橫縱座標
            if (!isPointerUp || i != event.getActionIndex()) {
                sumX += event.getX(i);
                sumY += event.getY(i);
            }
        }
        int pointerCount = event.getPointerCount();
        if (isPointerUp) pointerCount--; // 將擡起的手指的數量去掉
        // 計算多個手指的協同的中心位置,即用多個手指的橫縱座標的和 / 點數量 求出平均中心點座標
        // 用中心點座標作爲bitmap的移動位置
        float focusX = sumX / pointerCount;
        float focusY = sumY / pointerCount;
        // 因爲現在只要關注多指觸摸計算的中心點座標,所以按下、多指按下、多指鬆開的操作都是重新計算中心點座標
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
            case MotionEvent.ACTION_POINTER_UP:
                downX = focusX;
                downY = focusY;
                originalOffsetX = offsetX;
                originalOffsetY = offsetY;
                break;
            case MotionEvent.ACTION_MOVE:
                offsetX = focusX - downX + originalOffsetX;
                offsetY = focusY - downY + originalOffsetY;
                invalidate();
                break;
        }
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmap, offsetX, offsetY, paint);
    }
}

3.3 各自爲戰型

各自爲戰型:各個pointer做不同的事,互不影響。典型:支持多畫筆的畫板應用。

實現方式:在每個 MotionEvent.ACTION_DOWNMotionEvent.ACTION_POINTER_DOWN 中記錄下每個pointer的id,在 MotionEvent.ACTION_MOVE 中使用id對它們進行跟蹤。

demo:多指觸摸View實現畫板

public class MultiTouchView extends View {
    private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private SparseArray<Path> paths = new SparseArray<>();

    public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        paint.setStrokeWidth(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()));
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeCap(Paint.Cap.ROUND);
        paint.setStrokeJoin(Paint.Join.ROUND);
    }

    // 多指畫板
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                int actionIndex = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex);
                Path path = new Path();
                path.moveTo(event.getX(actionIndex), event.getY(actionIndex));
                paths.append(pointerId, path);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                for (int i = 0; i < event.getPointerCount(); i++) {
                    path = paths.get(i);
                    if (path != null) {
                        path.lineTo(event.getX(i), event.getY(i));
                    }
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                actionIndex = event.getActionIndex();
                pointerId = event.getPointerId(actionIndex);
                paths.remove(pointerId);
                invalidate();
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        for (int i = 0; i < paths.size(); i++) {
            Path path = paths.valueAt(i);
            if (path != null) {
                canvas.drawPath(path, paint);
            }
        }
    }
}

4 結合多點觸控的觸摸事件結構總結

  • 觸摸事件是按序列來分組的,每一組事件必然以 ACTION_DOWN 開頭,以 ACTION_UPACTION_CANCEL 結束

  • ACTION_POINTER_DOWNACTION_POINTER_UPACTION_MOVE 一樣,只是事件序列中的組成部分,並不會單獨分出新的事件序列

  • 觸摸事件序列是針對View的,而不是針對pointer的。[某個pointer的事件]這種說法是不正確的

  • 在一個觸摸事件裏,每個pointer除了x和y之外,還有index和id

  • [移動的那個手指]這個概念是僞概念,[尋找移動的那個手指]這個需求是個僞需求

發佈了199 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章