1. 程式人生 > >安卓自定義View進階-特殊控制元件的事件處理方案

安卓自定義View進階-特殊控制元件的事件處理方案

本文帶大家瞭解 Android 特殊形狀控制元件的事件處理方式,主要是利用了 Region 和 Matrix 的一些方法,超級實用的事件處理方案,相信看完本篇之後,任何奇葩控制元件的事件處理都會變得十分簡單。

不得不說,Android 對事件體系封裝的非常棒,即便對事件體系不太瞭解的人,只要簡單的呼叫方法就能使用,而且具有防呆設計,能夠保證事件流的完整性和統一性,最大可能性的避免了事件處理的混亂,著實令人佩服。
然而世界上並沒有絕對完美的東西,當”事件處理”遇上”自定義View”,一場好戲就開演了。

特殊形狀控制元件

在通常的情況下,自定義 View 直接使用系統的事件體系處理就行,我們也不需要特殊處理,然而當一些特殊的控制元件出現的時候,麻煩就來了,舉個栗子:

這是一個在遙控器上非常常見的按鍵佈局,注意中間上下左右選擇的部分,看起來十分簡單,然而當你真正準備在手機上實現的時候麻煩就出現了。因為所有的 View 預設都是矩形的,所以事件接收區域也是矩形的,如果直接使用系統提供的 View 來組合出一摸一樣的佈局也很簡單,但點選區域該如何處理?顯然有部分點選區域是在控制元件外面的,並且會產生重疊區域:

紅色方框表示 View 的可點選區域。

當我們面對這樣比較奇特的控制元件的時候,有很多處理辦法,比較投機的一種就是背景貼一個靜態圖,按鈕做成透明的,設定小一點,放在對應的位置,這樣可以保證不會誤觸,當然瞭如果想要點選效果可以在按鈕按下的時候更新一下背景圖,這樣雖然也可以,但是這樣會導致可點選區域變小,體驗效果變差,設計方案變得複雜,而且邏輯也不容易處理,是一種非常糟糕的設計。

當然了,看了我這麼多文章的小夥伴應該也猜到接下來要說什麼了,沒錯,就是自定義 View。當我們面對一些奇葩控制元件的時候,自定義 View 就變成了一種非常好用的處理方案。

相信小夥伴們看過 前面的文章 之後,對各種圖形的繪製已經不成問題了,所以我們直接處理重點問題。

注意:

本文中所有的 自定義View 均繼承自 CustomView ,這是一個自定義的超類,目的是簡化 自定義View 部分常用操作,你可以在 ViewSupport 中找到它以及關於它的簡介。
⚠️ 警告:測試本文章示例之前請關閉硬體加速。

特殊形狀控的點選區域判斷

要進行特殊形狀的點選判斷,要用到一個之前沒有使用過的類:Region。

Region 直接翻譯的意思是 地域,區域。在此處應該是區域的意思。它和 Path 有些類似,但 Path 可以是不封閉圖形,而 Region 總是封閉的。可以通過 setPath 方法將 Path 轉換為 Region。

本文中我們重點要使用到的是 Region 中的 contains 方法,這個方法可以判斷一個點是否包含在該區域內。

接下來是一個簡單的示例,判斷手指是否是在圓形區域內按下:

程式碼:

public class RegionClickView extends CustomView {
    Region circleRegion;
    Path circlePath;

    public RegionClickView(Context context) {
        super(context);
        mDeafultPaint.setColor(0xFF4E5268);
        circlePath = new Path();
        circleRegion = new Region();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // ▼在螢幕中間新增一個圓
        circlePath.addCircle(w/2, h/2, 300, Path.Direction.CW);
        // ▼將剪裁邊界設定為檢視大小
        Region globalRegion = new Region(-w, -h, w, h);
        // ▼將 Path 新增到 Region 中
        circleRegion.setPath(circlePath, globalRegion);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                int x = (int) event.getX();
                int y = (int) event.getY();

                // ▼點選區域判斷
                if (circleRegion.contains(x,y)){
                    Toast.makeText(this.getContext(),"圓被點選",Toast.LENGTH_SHORT).show();
                }
                break;
        }
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // ▼注意此處將全域性變數轉化為區域性變數,方便 GC 回收 canvas
        Path circle = circlePath;
        // 繪製圓
        canvas.drawPath(circle,mDeafultPaint);
    }
}

程式碼中比較重要的內容都用 ▼ 符號標記出來了。

上述程式碼非常簡單,就是建立了個 Path 並在其中新增圓形,之後將 Path 設定到 Region 中,當手指在螢幕上按下的時候判斷手指位置是否在 Region 區域內。

畫布變換後坐標轉換問題

還是本文一開始的例子,繪製一個上下左右選擇按鍵,這個控制元件是上下左右對稱的,熟悉我程式碼風格的小夥伴都知道,如果遇上這種問題,我肯定是要將座標系平移到這個控制元件中心的,這樣資料比較好計算,然而進行畫布變換操作會產生一個新問題:手指觸控的座標系和畫布座標系不統一,就可能引起手指觸控位置和繪製位置不統一。

舉個栗子:

畫布移動後在手指按下位置繪製一個圓,可以看到,直接拿手指觸控位置的座標來繪製會導致繪製位置不正確,兩者座標是相同的,但是由於座標系不同,導致實際顯示位置不同。

程式碼:

public class CanvasVonvertTouchTest extends CustomView{
    float down_x = -1;
    float down_y = -1;

    public CanvasVonvertTouchTest(Context context) {
        this(context, null);
    }

    public CanvasVonvertTouchTest(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()){
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                down_x = event.getX();
                down_y = event.getY();
                invalidate();
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                down_x = down_y = -1;
                invalidate();
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        float x = down_x;
        float y = down_y;

        drawTouchCoordinateSpace(canvas);       // 繪製觸控座標系 灰色

        // ▼注意畫布平移
        canvas.translate(mViewWidth/2, mViewHeight/2);  

        drawTranslateCoordinateSpace(canvas);    // 繪製平移後的座標系,紅色

        if (x == -1 && y == -1) return;          // 如果沒有就返回

        canvas.drawCircle(x,y,20,mDeafultPaint); // 在觸控位置繪製一個小圓
    }

    /**
     *  繪製觸控座標系,灰色,為了能夠顯示出座標系,將座標系位置稍微偏移了一點
     */
    private void drawTouchCoordinateSpace(Canvas canvas) {
        canvas.save();
        canvas.translate(10,10);
        CanvasAidUtils.set2DAxisLength(1000, 0, 1400, 0);
        CanvasAidUtils.setLineColor(Color.GRAY);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
        canvas.restore();
    }

    /**
     * 繪製平移後的座標系,紅色
     */
    private void drawTranslateCoordinateSpace(Canvas canvas) {
        CanvasAidUtils.set2DAxisLength(500, 500, 700, 700);
        CanvasAidUtils.setLineColor(Color.RED);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
    }
}

那麼問題來了,我們在之前的文章中講過,對映不同座標系的座標用 什麼來著?
是 Matrix。

如果看過我之前的文章但沒有想起來的說明你們根本沒有認真看,全部拖出去糟蹋 5 分鐘!
沒看過的點 Matrix原理Matrix詳解

Matrix 是一個矩陣,主要功能是座標對映,數值轉換。

那麼接下來我們就對上面的示例進行簡單的改造一下,讓觸控位置和實際繪製繪製重合。小白點和黑色的圓沒有完全重合是因為系統顯示觸控位置的繪製邏輯和我使用的繪製邏輯不太相同導致的。

程式碼:

注意:比較重要的修改位置用▼標記出來了。

public class CanvasVonvertTouchTest extends CustomView{
    float down_x = -1;
    float down_y = -1;

    public CanvasVonvertTouchTest(Context context) {
        this(context, null);
    }

    public CanvasVonvertTouchTest(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()){
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                // ▼ 注意此處使用 getRawX,而不是 getX
                down_x = event.getRawX();
                down_y = event.getRawY();
                invalidate();
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                down_x = down_y = -1;
                invalidate();
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        float[] pts = {down_x, down_y};

        drawTouchCoordinateSpace(canvas);            // 繪製觸控座標系,灰色
        // ▼注意畫布平移
        canvas.translate(mViewWidth/2, mViewHeight/2);  

        drawTranslateCoordinateSpace(canvas);        // 繪製平移後的座標系,紅色

        if (pts[0] == -1 && pts[1] == -1) return;    // 如果沒有就返回

        // ▼ 獲得當前矩陣的逆矩陣
        Matrix invertMatrix = new Matrix();
        canvas.getMatrix().invert(invertMatrix);

        // ▼ 使用 mapPoints 將觸控位置轉換為畫布座標
        invertMatrix.mapPoints(pts);

        // 在觸控位置繪製一個小圓
        canvas.drawCircle(pts[0],pts[1],20,mDeafultPaint);
    }

    /**
     *  繪製觸控座標系,顏色為灰色,為了能夠顯示出座標系,將座標系位置稍微偏移了一點
     */
    private void drawTouchCoordinateSpace(Canvas canvas) {
        canvas.save();
        canvas.translate(10,10);
        CanvasAidUtils.set2DAxisLength(1000, 0, 1400, 0);
        CanvasAidUtils.setLineColor(Color.GRAY);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
        canvas.restore();
    }

    /**
     * 繪製平移後的座標系,顏色為紅色
     */
    private void drawTranslateCoordinateSpace(Canvas canvas) {
        CanvasAidUtils.set2DAxisLength(500, 500, 700, 700);
        CanvasAidUtils.setLineColor(Color.RED);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
        CanvasAidUtils.draw2DCoordinateSpace(canvas);
    }
}

其實核心部分就這兩點:

// ▼ 注意此處使用 getRawX,而不是 getX
down_x = event.getRawX();
down_y = event.getRawY();

// -------------------------------------

// ▼ 獲得當前矩陣的逆矩陣
Matrix invertMatrix = new Matrix();
canvas.getMatrix().invert(invertMatrix);

// ▼ 使用 mapPoints 將觸控位置轉換為畫布座標
invertMatrix.mapPoints(pts);
  1. 使用全域性座標系
  2. 使用逆矩陣的 mapPoints

原理嘛,其實非常簡單,我們在畫布上正常的繪製,需要將畫布座標系轉換為全域性座標系後才能真正的繪製內容。所以我們反著來,將獲得到的全域性座標系座標使用當前畫布的逆矩陣轉化一下,就轉化為當前畫布的座標系座標了,如果對 Matrix原理Matrix詳解 理解了,即便我不說你們也肯定會想到這個方案的。

仿遙控器按鈕程式碼示例

在解決了上述兩大難題之後,相信不論形狀如何奇葩的自定義控制元件,基本上都難不倒大家了,最後用一個簡單的示例作為結尾,還是文章開頭所舉的例子,核心內容就是上面講的兩個東西。

程式碼:

public class RemoteControlMenu extends CustomView {
    Path up_p, down_p, left_p, right_p, center_p;
    Region up, down, left, right, center;

    Matrix mMapMatrix = null;

    int CENTER = 0;
    int UP = 1;
    int RIGHT = 2;
    int DOWN = 3;
    int LEFT = 4;
    int touchFlag = -1;
    int currentFlag = -1;

    MenuListener mListener = null;

    int mDefauColor = 0xFF4E5268;
    int mTouchedColor = 0xFFDF9C81;


    public RemoteControlMenu(Context context) {
        this(context, null);
    }

    public RemoteControlMenu(Context context, AttributeSet attrs) {
        super(context, attrs);

        up_p = new Path();
        down_p = new Path();
        left_p = new Path();
        right_p = new Path();
        center_p = new Path();

        up = new Region();
        down = new Region();
        left = new Region();
        right = new Region();
        center = new Region();

        mDeafultPaint.setColor(mDefauColor);
        mDeafultPaint.setAntiAlias(true);

        mMapMatrix = new Matrix();

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mMapMatrix.reset();

        // 注意這個區域的大小
        Region globalRegion = new Region(-w, -h, w, h);
        int minWidth = w > h ? h : w;
        minWidth *= 0.8;

        int br = minWidth / 2;
        RectF bigCircle = new RectF(-br, -br, br, br);

        int sr = minWidth / 4;
        RectF smallCircle = new RectF(-sr, -sr, sr, sr);

        float bigSweepAngle = 84;
        float smallSweepAngle = -80;

        // 根據檢視大小,初始化 Path 和 Region
        center_p.addCircle(0, 0,