Android 自定義 View 實戰之 StickerView

FlyingSnowBean發表於2017-05-18

本篇文章為利用Matrix自定義View三部曲的第一部曲。

雖然Android內建了許多View供開發者組合和使用,但其多樣性還是不足,在很多場景或功能需求下,Android原生自帶的控制元件並不足以實現需求,這時我們就需要自定義滿足我們需求的View。

本文會講解一個自定義View的設計和開發過程,在閱讀之前希望大家有最基礎的自定義View的知識,以及Matrix類的基本使用。

起步

在很多圖片社交的應用,例如Lofter、Play、In等應用中,都會有新增各種可愛的貼圖到圖片上的功能,然後我們可以對圖片進行移動、旋轉、縮放、翻轉之類的操作,本文製作的View正是為了實現這個功能。最終我們將要實現的效果如下圖:

Android 自定義 View 實戰之 StickerView

專案地址:github.com/wuapnjie/St…

簡單思考(確定大致思路)

要實現這樣的效果,我們肯定需要對圖片進行操作,在自定義的View中,我們可以在onDraw()方法將我們的圖片(通常為Bitmap)畫到View上。

 protected void onDraw(Canvas canvas) {
     super.onDraw(canvas);
        canvas.drawBitmap(bitmap,matrix,paint);
 }複製程式碼

drawBitmap()方法有許多過載方法,但是利用Matrix來控制畫在View上的圖片是最靈活最簡單的。(不熟悉Matrix類可以先去了解下,這裡就不介紹基礎的知識了)

利用Matrix可以方便的控制圖片的位置,旋轉角度,縮放比。

再看我們的功能,用不同的手勢來操作圖片,既然利用Matrix可以操作圖片,那麼我們只需要在View的onTouchEvent()方法中監聽不同的手勢操作,再對其Matrix進行變換,重繪View即可。整個思路流程就很清楚了。

仔細思考(決定結構)

有了思路,那麼我們就要來考慮我們應該怎麼樣組織程式碼,怎麼樣設計程式碼的結構。當然這個View並不複雜,設計起來也不復雜。

首先,對於貼紙功能,在沒有一張貼紙時就只顯示一張圖片,而這個功能ImageView已經為我們實現了,於是StickerView應該繼承自ImageView,並且重寫onDraw()onTouchEvent()方法。

其次,因為一張圖片上可以新增多張貼紙,而每一張貼紙都需要一個Matrix來控制其相關變換,所以我們可以設計一個類封裝一下,方便對貼紙的操作。

public abstract class Sticker {
    protected Matrix mMatrix;
      public abstract void draw(Canvas canvas);
      ……
}複製程式碼

因為貼紙可能是Bitmap,也就是普通的圖片,但是我們也可以新增氣泡啊,標籤啊之類的自定義的Drawable,
當然也可能是各種圖形,為了其擴充套件性,這裡將Sticker類抽象。

擴充套件的DrawableSticker

public class DrawableSticker extends Sticker {
    private Drawable mDrawable;
    private Rect mRealBounds;
    ……
      @Override
    public void draw(Canvas canvas) {
        canvas.save();
        canvas.concat(mMatrix);
        mDrawable.setBounds(mRealBounds);
        mDrawable.draw(canvas);
        canvas.restore();
    }
      ……
}複製程式碼

那麼大致的結構就確定了,在View的onTouchEvent()中,我們根據手勢改變Sticker的Matrix,並在onDraw()方法中將Sticker畫出。

protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
      ……
    sticker.draw(canvas);
      ……
}複製程式碼

實現

在有了思路和一個結構後,大致已經成功了一半,接下來就是一個個功能的實現,和一遍遍的除錯了。

由於我們可以新增不止一個Sticker,所以我們的StickerView需要保有對所有新增的Sticker應用,這裡可以用一個List集合來儲存。而對於當前正在操作的Sticker引用需要額外儲存。

因為對於不同的手勢,我們所做出的操作不同,那麼我們需要在內部宣告所有存在的狀態和一個當前狀態

public class StickerView extends ImageView {
    private enum ActionMode {
        NONE,   //nothing
        DRAG,   //drag the sticker with your finger
        ZOOM_WITH_TWO_FINGER,   //zoom in or zoom out the sticker and rotate the sticker with two finger
        ZOOM_WITH_ICON,    //zoom in or zoom out the sticker and rotate the sticker with icon
        DELETE,  //delete the handling sticker
        FLIP_HORIZONTAL //horizontal flip the sticker
    }
      private ActionMode mCurrentMode = ActionMode.NONE;

    private List<Sticker> mStickers = new ArrayList<>();
    private Sticker mHandlingSticker;
      ……
}複製程式碼

接下來就是一個一個功能實現,但肯定的是,最先需要實現的就是將貼紙新增進來的方法。

新增貼紙

實現起來也很簡單,這裡就是new一個Sticker物件,並把它加入到我們的List中並重繪,注意,我們預設將Sticker縮放至原來的一半,並放在StickerView中央。

public void addSticker(Drawable stickerDrawable) {
        Sticker drawableSticker = new DrawableSticker(stickerDrawable);

        float offsetX = (getWidth() - drawableSticker.getWidth()) / 2;
        float offsetY = (getHeight() - drawableSticker.getHeight()) / 2;
        drawableSticker.getMatrix().postTranslate(offsetX, offsetY);

        float scaleFactor;
        if (getWidth() < getHeight()) {
            scaleFactor = (float) getWidth() / stickerDrawable.getIntrinsicWidth();
        } else {
            scaleFactor = (float) getHeight() / stickerDrawable.getIntrinsicWidth();
        }
        drawableSticker.getMatrix().postScale(scaleFactor / 2, scaleFactor / 2, getWidth() / 2, getHeight() / 2);

        mHandlingSticker = drawableSticker;
        mStickers.add(drawableSticker);

        invalidate();
}複製程式碼

找到貼紙

在我們的貼紙物件被新增進來後我們才可以繼續接下來的操作,在我們觸控螢幕時,要判斷是否按在貼紙區域,按在哪個貼紙上。實現比較簡單,我們的每個Sticker都有一個矩形範圍,在經過移動縮放之類的操作後也可以通過Matrix來輕鬆得到那個矩形區域(Rect類),只需要判斷這個範圍是否包含我們按下的點,而這一步應該在Touch事件的ACTION_DOWN事件中進行。

switch (action) {
     case MotionEvent.ACTION_DOWN:
         mCurrentMode = ActionMode.DRAG;
         mDownX = event.getX();
         mDownY = event.getY();
         mHandlingSticker = findHandlingSticker();          
          ……
}複製程式碼

其中findHandlingSticker()正是做了這樣一些事情

private Sticker findHandlingSticker() {
    for (int i = mStickers.size() - 1; i >= 0; i--) {
        if (isInStickerArea(mStickers.get(i), mDownX, mDownY)) {
            return mStickers.get(i);
         }
    }
    return null;
}複製程式碼

移動貼紙

找到了我們要操作的Sticker後,我們就可以對其進行操作了,移動操作最為簡單,只涉及一根手指,在ACTION_DOWN事件中我們記錄下當前Sticker的狀態和事件起始座標,在ACTION_MOVE事件中,我們利用當前點的座標計算出實際偏移量,利用Matrix的postTransition()方法讓Sticker做出隨手指的移動。

mMoveMatrix.set(mDownMatrix);
mMoveMatrix.postTranslate(event.getX() - mDownX, event.getY() - mDownY);
mHandlingSticker.getMatrix().set(mMoveMatrix);複製程式碼

縮放與旋轉貼紙

一般的縮放與旋轉操作都是需要兩根手指,所以我們需要在ACTION_POINT_DOWN事件中監聽第二根手指按下。這時我們還需要計算出兩根手指之間的距離以及中心點還有角度,因為我們要讓Sticker以這個中心點為中心縮放旋轉,在ACTION_MOVE事件中以新的兩指尖距離/起始兩指尖距離作為縮放比縮放。以新的角度-起始角度作為旋轉角。

switch (action) {
     case MotionEvent.ACTION_POINTER_DOWN:
        mOldDistance = calculateDistance(event);
        mOldRotation = calculateRotation(event);
        mMidPoint = calculateMidPoint(event);          
          ……
}複製程式碼

相應的縮放與旋轉,利用Matrix的postScalepostRotate方法實現

float newDistance = calculateDistance(event);
float newRotation = calculateRotation(event);

mMoveMatrix.set(mDownMatrix);
mMoveMatrix.postScale(newDistance / mOldDistance, newDistance / mOldDistance, mMidPoint.x, mMidPoint.y);
mMoveMatrix.postRotate(newRotation - mOldRotation, mMidPoint.x, mMidPoint.y);

mHandlingSticker.getMatrix().set(mMoveMatrix);複製程式碼

新增選中效果

在經過上面的步驟後,我們的StickerView已經可以新增貼紙,用手勢操縱貼紙移動,縮放,旋轉了,但是我們並沒有對選中的貼紙進行特殊處理,因為一般的應用對於選中的貼紙,都會用一個邊框圍住,並在相應的邊框邊角顯示一些操作按鈕。因為這個按鈕有圖示,所以我們也可以把其作為一個Sticker,只是還需要一個位置的x,y值。

public class BitmapStickerIcon extends DrawableSticker {
    private float x;
    private float y;
      ……
}複製程式碼

因為對於每個Sticker的邊框及其座標是很容易獲得的,所以我們只需要在onDraw方法中在正在處理的Sticker周圍畫上邊框和按鈕就可以了。下面的程式碼獲得了選中Sticker的邊角座標,並將操作按鈕畫在相應位置。

if (mHandlingSticker != null && !mLooked) {

    float[] bitmapPoints = getStickerPoints(mHandlingSticker);

    float x1 = bitmapPoints[0];
    float y1 = bitmapPoints[1];
    float x2 = bitmapPoints[2];
    float y2 = bitmapPoints[3];
    float x3 = bitmapPoints[4];
    float y3 = bitmapPoints[5];
    float x4 = bitmapPoints[6];
    float y4 = bitmapPoints[7];

    canvas.drawLine(x1, y1, x2, y2, mBorderPaint);
    canvas.drawLine(x1, y1, x3, y3, mBorderPaint);
    canvas.drawLine(x2, y2, x4, y4, mBorderPaint);
    canvas.drawLine(x4, y4, x3, y3, mBorderPaint);

    float rotation = calculateRotation(x3, y3, x4, y4);
    //draw delete icon
    canvas.drawCircle(x1, y1, mIconRadius, mBorderPaint);
    mDeleteIcon.setX(x1);
    mDeleteIcon.setY(y1);
    mDeleteIcon.getMatrix().reset();

    mDeleteIcon.getMatrix().postRotate(
                    rotation, mDeleteIcon.getWidth() / 2, mDeleteIcon.getHeight() / 2);
    mDeleteIcon.getMatrix().postTranslate(
                    x1 - mDeleteIcon.getWidth() / 2, y1 - mDeleteIcon.getHeight() / 2);

    mDeleteIcon.draw(canvas);

            //draw zoom icon
    canvas.drawCircle(x4, y4, mIconRadius, mBorderPaint);
    mZoomIcon.setX(x4);
    mZoomIcon.setY(y4);

    mZoomIcon.getMatrix().reset();
    mZoomIcon.getMatrix().postRotate(
                    45f + rotation, mZoomIcon.getWidth() / 2, mZoomIcon.getHeight() / 2);

    mZoomIcon.getMatrix().postTranslate(
                    x4 - mZoomIcon.getWidth() / 2, y4 - mZoomIcon.getHeight() / 2);

    mZoomIcon.draw(canvas);

    //draw flip icon
    canvas.drawCircle(x2, y2, mIconRadius, mBorderPaint);
    mFlipIcon.setX(x2);
    mFlipIcon.setY(y2);

    mFlipIcon.getMatrix().reset();
    mFlipIcon.getMatrix().postRotate(
                    rotation, mDeleteIcon.getWidth() / 2, mDeleteIcon.getHeight() / 2);
    mFlipIcon.getMatrix().postTranslate(
                    x2 - mFlipIcon.getWidth() / 2, y2 - mFlipIcon.getHeight() / 2);

    mFlipIcon.draw(canvas);
}複製程式碼

總結

這樣,我們大致完成了StickerView的所有功能,當然上面並沒有太完整的程式碼,只是一些程式碼片段,但是已經說明了大致的思路及操作,想了解更多細節可以去檢視原始碼。我們在自定義View時,首先最需要的是一個思路,有了思路之後要想其程式碼結構,在這兩塊都想好了以後再開發其功能,會事半功倍。

希望可以對你有幫助。如果有什麼疑問,可以隨時聯絡我,歡迎提issue和pr。

下一篇:Android自定義View實戰之拼圖PuzzleView

相關文章