Android塗鴉畫板原理詳解——從初級到高階(二)

hzw1490152780934發表於2019-03-02

前言

前面寫了《Android塗鴉畫板原理詳解——從初級到高階(一)》,講了塗鴉原理初級和中級的應用,現在講解高階應用。如果沒有看過前面一篇文章的同學,建議先去看看哈。

準備

高階塗鴉涉及到圖片操作,包括對圖片進行縮放移動、塗鴉等,這裡涉及到矩陣的變換。關於矩陣變換的知識,請檢視我的另一篇文章《淺談矩陣變換——Matrix》。根據文中的介紹,接下來使用變換座標系的空間想象去理解塗鴉中涉及到的矩陣變換。

高階塗鴉

高階塗鴉支援對圖片塗鴉, 可移動縮放圖片。思路如下:

  1. 建立自定義View: AdvancedDoodleView,由外部建立時傳入Bitmap影象物件。
  2. 在View大小確定時候的回撥onSizeChanged()中進行初始化操作,計算圖片居中顯示的所需引數,如圖片縮放倍數和偏移值。
  3. 定義PathItem類,封裝塗鴉軌跡,包括Path和偏移值等資訊。
class PathItem {
    Path mPath = new Path(); // 塗鴉軌跡
    float mX, mY; // 軌跡偏移值
}
複製程式碼
  1. 單擊時需要判斷是否點中某個塗鴉,Path提供了介面computeBounds()計算當前圖形的矩形範圍,可以通過判斷單擊的點是否在矩形範圍內判斷。使用TouchGestureDetector識別單擊和滑動手勢。(TouchGestureDetector在我另一個專案Androids中,使用時需要匯入依賴)

  2. 滑動過程中需要判斷當前是否有選中的塗鴉,如果有則對該塗鴉進行移動,把偏移值記錄在PathItem中;沒有則繪製新的塗鴉軌跡。

  3. 監聽雙指縮放手勢,計算圖片縮放的倍數。

4-6中涉及到的觸控座標要換算成對應圖片座標系中的座標,稍後詳細講解 )

  1. 在AdvancedDoodleView的onDraw方法中,根據圖片縮放倍數和偏移值繪製圖片;繪製每個PathItem之前根據偏移值移動畫布。

座標對映

選擇畫布和圖片共用一個座標系,瞭解圖片的位置資訊後,最後需要處理的就是,螢幕座標系與圖片(畫布)座標系的對映,即把螢幕上滑動的軌跡投射到圖片中。

Android塗鴉畫板原理詳解——從初級到高階(二)

從上圖的分析中,我們可以得出如下對映關係:

圖片座標x=(螢幕座標x-圖片在螢幕座標系x軸上的偏移量)/圖片縮放倍數

圖片座標y=(螢幕座標y-圖片在螢幕座標系y軸上的偏移量)/圖片縮放倍數
複製程式碼

(注意,圖片是以左上角為中心進行縮放的)

對應程式碼:

/**
 * 將螢幕觸控座標x轉換成在圖片中的座標x
 */
public final float toX(float touchX) {
    return (touchX - mBitmapTransX) / mBitmapScale;
}

/**
 * 將螢幕觸控座標y轉換成在圖片中的座標y
 */
public final float toY(float touchY) {
    return (touchY - mBitmapTransY) / mBitmapScale;
}
複製程式碼

可見,螢幕座標投射到圖片上時,需要減去偏移量,因為圖片的位置是一直不變的,我們對圖片進行偏移,其實是對View的畫布進行偏移。

最終實現效果如下:

Android塗鴉畫板原理詳解——從初級到高階(二)

程式碼如下:

public class AdvancedDoodleView extends View {

    private final static String TAG = "AdvancedDoodleView";

    private Paint mPaint = new Paint();
    private List<PathItem> mPathList = new ArrayList<>(); // 儲存塗鴉軌跡的集合
    private TouchGestureDetector mTouchGestureDetector; // 觸控手勢監聽
    private float mLastX, mLastY;
    private PathItem mCurrentPathItem; // 當前的塗鴉軌跡
    private PathItem mSelectedPathItem; // 選中的塗鴉軌跡

    private Bitmap mBitmap;
    private float mBitmapTransX, mBitmapTransY, mBitmapScale = 1;

    public AdvancedDoodleView(Context context, Bitmap bitmap) {
        super(context);
        mBitmap = bitmap;

        // 設定畫筆
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(20);
        mPaint.setAntiAlias(true);
        mPaint.setStrokeCap(Paint.Cap.ROUND);

        // 由手勢識別器處理手勢
        mTouchGestureDetector = new TouchGestureDetector(getContext(), new TouchGestureDetector.OnTouchGestureListener() {

            RectF mRectF = new RectF();

            // 縮放手勢操作相關
            Float mLastFocusX;
            Float mLastFocusY;
            float mTouchCentreX, mTouchCentreY;

            @Override
            public boolean onScaleBegin(ScaleGestureDetectorApi27 detector) {
                Log.d(TAG, "onScaleBegin: ");
                mLastFocusX = null;
                mLastFocusY = null;
                return true;
            }

            @Override
            public void onScaleEnd(ScaleGestureDetectorApi27 detector) {
                Log.d(TAG, "onScaleEnd: ");
            }

            @Override
            public boolean onScale(ScaleGestureDetectorApi27 detector) { // 雙指縮放中
                Log.d(TAG, "onScale: ");
                // 螢幕上的焦點
                mTouchCentreX = detector.getFocusX();
                mTouchCentreY = detector.getFocusY();

                if (mLastFocusX != null && mLastFocusY != null) { // 焦點改變
                    float dx = mTouchCentreX - mLastFocusX;
                    float dy = mTouchCentreY - mLastFocusY;
                    // 移動圖片
                    mBitmapTransX = mBitmapTransX + dx;
                    mBitmapTransY = mBitmapTransY + dy;
                }

                // 縮放圖片
                mBitmapScale = mBitmapScale * detector.getScaleFactor();
                if (mBitmapScale < 0.1f) {
                    mBitmapScale = 0.1f;
                }
                invalidate();

                mLastFocusX = mTouchCentreX;
                mLastFocusY = mTouchCentreY;

                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) { // 單擊選中
                float x = toX(e.getX()), y = toY(e.getY());
                boolean found = false;
                for (PathItem path : mPathList) { // 繪製塗鴉軌跡
                    path.mPath.computeBounds(mRectF, true); // 計算塗鴉軌跡的矩形範圍
                    mRectF.offset(path.mX, path.mY); // 加上偏移
                    if (mRectF.contains(x, y)) { // 判斷是否點中塗鴉軌跡的矩形範圍內
                        found = true;
                        mSelectedPathItem = path;
                        break;
                    }
                }
                if (!found) { // 沒有點中任何塗鴉
                    mSelectedPathItem = null;
                }
                invalidate();
                return true;
            }

            @Override
            public void onScrollBegin(MotionEvent e) { // 滑動開始
                Log.d(TAG, "onScrollBegin: ");
                float x = toX(e.getX()), y = toY(e.getY());
                if (mSelectedPathItem == null) {
                    mCurrentPathItem = new PathItem(); // 新的塗鴉
                    mPathList.add(mCurrentPathItem); // 新增的集合中
                    mCurrentPathItem.mPath.moveTo(x, y);
                }
                mLastX = x;
                mLastY = y;
                invalidate(); // 重新整理
            }

            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // 滑動中
                Log.d(TAG, "onScroll: " + e2.getX() + " " + e2.getY());
                float x = toX(e2.getX()), y = toY(e2.getY());
                if (mSelectedPathItem == null) { // 沒有選中的塗鴉
                    mCurrentPathItem.mPath.quadTo(
                            mLastX,
                            mLastY,
                            (x + mLastX) / 2,
                            (y + mLastY) / 2); // 使用貝塞爾曲線 讓塗鴉軌跡更圓滑
                } else { // 移動選中的塗鴉
                    mSelectedPathItem.mX = mSelectedPathItem.mX + x - mLastX;
                    mSelectedPathItem.mY = mSelectedPathItem.mY + y - mLastY;
                }
                mLastX = x;
                mLastY = y;
                invalidate(); // 重新整理
                return true;
            }

            @Override
            public void onScrollEnd(MotionEvent e) { // 滑動結束
                Log.d(TAG, "onScrollEnd: ");
                float x = toX(e.getX()), y = toY(e.getY());
                if (mSelectedPathItem == null) {
                    mCurrentPathItem.mPath.quadTo(
                            mLastX,
                            mLastY,
                            (x + mLastX) / 2,
                            (y + mLastY) / 2); // 使用貝塞爾曲線 讓塗鴉軌跡更圓滑
                    mCurrentPathItem = null; // 軌跡結束
                }
                invalidate(); // 重新整理
            }

        });

        // 針對塗鴉的手勢引數設定
        // 下面兩行繪畫場景下應該設定間距為大於等於1,否則設為0雙指縮放後抬起其中一個手指仍然可以移動
        mTouchGestureDetector.setScaleSpanSlop(1); // 手勢前識別為縮放手勢的雙指滑動最小距離值
        mTouchGestureDetector.setScaleMinSpan(1); // 縮放過程中識別為縮放手勢的雙指最小距離值
        mTouchGestureDetector.setIsLongpressEnabled(false);
        mTouchGestureDetector.setIsScrollAfterScaled(false);
    }

    @Override
    protected void onSizeChanged(int width, int height, int oldw, int oldh) { //view繪製完成時 大小確定
        super.onSizeChanged(width, height, oldw, oldh);
        int w = mBitmap.getWidth();
        int h = mBitmap.getHeight();
        float nw = w * 1f / getWidth();
        float nh = h * 1f / getHeight();
        float centerWidth, centerHeight;
        // 1.計算使圖片居中的縮放值
        if (nw > nh) {
            mBitmapScale = 1 / nw;
            centerWidth = getWidth();
            centerHeight = (int) (h * mBitmapScale);
        } else {
            mBitmapScale = 1 / nh;
            centerWidth = (int) (w * mBitmapScale);
            centerHeight = getHeight();
        }
        // 2.計算使圖片居中的偏移值
        mBitmapTransX = (getWidth() - centerWidth) / 2f;
        mBitmapTransY = (getHeight() - centerHeight) / 2f;
        invalidate();
    }

    /**
     * 將螢幕觸控座標x轉換成在圖片中的座標
     */
    public final float toX(float touchX) {
        return (touchX - mBitmapTransX) / mBitmapScale;
    }

    /**
     * 將螢幕觸控座標y轉換成在圖片中的座標
     */
    public final float toY(float touchY) {
        return (touchY - mBitmapTransY) / mBitmapScale;
    }


    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        boolean consumed = mTouchGestureDetector.onTouchEvent(event); // 由手勢識別器處理手勢
        if (!consumed) {
            return super.dispatchTouchEvent(event);
        }
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // 畫布和圖片共用一個座標系,只需要處理螢幕座標系到圖片(畫布)座標系的對映關係(toX toY)
        canvas.translate(mBitmapTransX, mBitmapTransY);
        canvas.scale(mBitmapScale, mBitmapScale);

        // 繪製圖片
        canvas.drawBitmap(mBitmap, 0, 0, null);

        for (PathItem path : mPathList) { // 繪製塗鴉軌跡
            canvas.save();
            canvas.translate(path.mX, path.mY); // 根據塗鴉軌跡偏移值,偏移畫布使其畫在對應位置上
            if (mSelectedPathItem == path) {
                mPaint.setColor(Color.YELLOW); // 點中的為黃色
            } else {
                mPaint.setColor(Color.RED); // 其他為紅色
            }
            canvas.drawPath(path.mPath, mPaint);
            canvas.restore();
        }
    }

    /**
     * 封裝塗鴉軌跡物件
     */
    private static class PathItem {
        Path mPath = new Path(); // 塗鴉軌跡
        float mX, mY; // 軌跡偏移值
    }
}
複製程式碼

使用時通過如下程式碼新增到父容器中:

// 高階級塗鴉
        ViewGroup advancedContainer = findViewById(R.id.container_advanced_doodle);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.thelittleprince2);
        AdvancedDoodleView advancedDoodleView = new AdvancedDoodleView(this, bitmap);
        advancedContainer.addView(advancedDoodleView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
複製程式碼

後續

塗鴉最核心的原理就是這樣,希望各位能理解透。至於如何在圖片中新增文字圖片或者其他類似塗鴉的,其實跟程式碼中定義的PathItem代表塗鴉軌跡一樣,我們用新的類封裝新的塗鴉型別即可,然後儲存相關資訊,最終在畫布上繪製出來即可。

塗鴉原理的系列文章終於講完了!謝謝大家關注和支援!謝謝!!!

上面的程式碼在我的開源框架的Demo裡>>>>Doodle塗鴉原理教程程式碼

最後請大家多多支援我的專案>>>>開源專案Doodle!一個功能強大,可自定義和可擴充套件的塗鴉框架

相關文章