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

hzw1490152780934發表於2018-09-14

準備

前段時間,釋出了多功能畫板&開源塗鴉框架Doodle,得到了一些小夥伴的關注。但由於框架程式碼較多,一開始較難理解,有不少人詢問了相關的實現細節。我發現不少初學者對基本的塗鴉原理不熟悉,因此我決定寫一些簡單的例子,用於說明最基本的的塗鴉原理,這也是多功能畫板&開源塗鴉框架Doodle最核心的地方。

好的,在講解之前,我希望小夥伴們對View的繪製流程有一定的瞭解,還不熟悉的同學可以先看看我之前的文章《View的繪製流程》,因為下面的塗鴉我們用到了自定義View的知識。

初級塗鴉

我們要實現最簡單的塗鴉,手指在螢幕上滑動時繪製滑動軌跡。思路如下:

  1. 建立自定義View: SimpleDoodleView
  2. 使用TouchGestureDetector識別滑動手勢。(TouchGestureDetector在我另一個專案Androids中,使用時需要匯入依賴)
  3. 將手勢滑動的點記錄在系統類Path中。Path可以支援貝塞爾曲線等各種圖形的繪製。
  4. 在自定義View的onDraw方法中通過Canvas.drawPath()繪製記錄的Path,把塗鴉軌跡繪製出來。

實現效果:

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

程式碼如下:

public class SimpleDoodleView extends View {

    private final static String TAG = "SimpleDoodleView";

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

    public SimpleDoodleView(Context context) {
        super(context);
        // 設定畫筆
        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() {

            @Override
            public void onScrollBegin(MotionEvent e) { // 滑動開始
                Log.d(TAG, "onScrollBegin: ");
                mCurrentPath = new Path(); // 新的塗鴉
                mPathList.add(mCurrentPath); // 新增的集合中
                mCurrentPath.moveTo(e.getX(), e.getY());
                mLastX = e.getX();
                mLastY = e.getY();
                invalidate(); // 重新整理
            }

            @Override
            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // 滑動中
                Log.d(TAG, "onScroll: " + e2.getX() + " " + e2.getY());
                mCurrentPath.quadTo(
                        mLastX,
                        mLastY,
                        (e2.getX() + mLastX) / 2,
                        (e2.getY() + mLastY) / 2); // 使用貝塞爾曲線 讓塗鴉軌跡更圓滑
                mLastX = e2.getX();
                mLastY = e2.getY();
                invalidate(); // 重新整理
                return true;
            }

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

        });
    }

    @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) {
        for (Path path : mPathList) { // 繪製塗鴉軌跡
            canvas.drawPath(path, mPaint);
        }
    }
}
複製程式碼

使用時直接在佈局檔案XML裡新增自定義SimpleDoodleView,或者通過如下程式碼新增到父容器中:

// 初級塗鴉
ViewGroup simpleContainer = findViewById(R.id.container_simple_doodle);
SimpleDoodleView simpleDoodleView = new SimpleDoodleView(this);
simpleContainer.addView(simpleDoodleView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
複製程式碼

程式碼很簡單,這裡沒有涉及到座標換算,直接就是滑到View的哪裡就直接在該位置繪製塗鴉,希望小夥伴們把上面的程式碼手動敲一遍,接下來就開始講中級塗鴉啦。

中級塗鴉

中級塗鴉要實現的效果:在初級塗鴉的基礎上,單擊時可以選擇某個塗鴉,進行移動。思路如下:

  1. 建立自定義View: MiddleDoodleView
  2. 定義PathItem類,封裝塗鴉軌跡,包括Path和偏移值等資訊。
class PathItem {
    Path mPath = new Path(); // 塗鴉軌跡
    float mX, mY; // 軌跡偏移值
}
複製程式碼
  1. 單擊時需要判斷是否點中某個塗鴉,Path提供了介面computeBounds()計算當前圖形的矩形範圍,可以通過判斷單擊的點是否在矩形範圍內判斷。使用TouchGestureDetector識別單擊和滑動手勢。(TouchGestureDetector在我另一個專案Androids中,使用時需要匯入依賴)

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

  3. 在MiddleDoodleView的onDraw方法中,繪製每個PathItem之前根據偏移值移動畫布。

實現效果:

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

程式碼如下:

public class MiddleDoodleView extends View {

    private final static String TAG = "MiddleDoodleView";

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

    public MiddleDoodleView(Context context) {
        super(context);
        // 設定畫筆
        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();

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

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

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

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

        });
    }

    @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) {
        for (PathItem path : mPathList) { // 繪製塗鴉軌跡
            canvas.save(); // 1.儲存畫布狀態,下面要變換畫布
            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(); // 2.恢復畫布狀態,繪製完一個塗鴉軌跡後取消上面的畫布變換,不影響下一個
        }
    }

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

使用時直接在佈局檔案XML裡新增自定義MiddleDoodleView,或者通過如下程式碼新增到父容器中:

// 中級塗鴉
ViewGroup middleContainer = findViewById(R.id.container_middle_doodle);
MiddleDoodleView middleDoodleView = new MiddleDoodleView(this);
middleContainer.addView(middleDoodleView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
複製程式碼

中級塗鴉的程式碼也不多,由於先前的塗鴉可以移動,所以在繪製塗鴉時需要根據移動的偏移值偏移畫布。這裡簡單應用了矩陣變換的知識,如果不太理解的小夥伴也不用著急,後面的高階塗鴉中會降到矩陣變換的知識。

後續

初中級的塗鴉並沒有涉及到對圖片的操作,所以相對簡單點,希望大夥可以理解透他們的原理,後面的高階塗鴉講涉及到圖片操作,對圖片進行縮放移動,就相對複雜很多,我會盡全力講解明白的~因此後面會單獨出一篇文章講解,請大家多多關注和支援!謝謝!!!

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

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

相關文章