1. 程式人生 > >Android自定義 view之圖片裁剪從設計到實現

Android自定義 view之圖片裁剪從設計到實現

android圖片剪下是常用的功能,因為部落格開發的是SDK不涉及到activity,所以就需要自定義裁剪功能,參閱了網上的大部分資料後,在github上一個封裝好的裁剪庫cropper,正好符合要求,本著拿來主義的思想,直接把原始碼clone嵌入到專案裡,然後使用。不過因為github 的這個裁剪庫比較大,而且提供的功能好多都用不到,於是乎博主在研究完其實現的基本思路之後,變大刀闊斧的修修改改,比如去掉了裁剪框設定固定寬高比的功能等等,甚至有時候覺得某些類命名覺得不合理我也該rename了一下,修改過後的原始檔大小由原來的100多k減少到了50k(因為專案組要求sdk的大小不能太大),算是滿足了專案裁剪的要求(修改過後

github原始碼奉上)。

實現效果圖:
這裡寫圖片描述
但是之所以覺得這個圖片庫值得研究就是因為從程式碼設計的角度來說,作者可以說是將面向物件的理念應用的很贊,強烈推薦讀者去研究下github上這個庫的原始碼,作者程式碼寫的確實很優雅。

當然自定義圖片裁剪功能也不是太複雜的功能,要是做的話也能做出來,核心就是呼叫createBitmap方法:

//圖片裁剪的核心功能
Bitmap.createBitmap(originalBitmap,//原圖
                 cropX,//圖片裁剪橫座標開始位置
                 cropY,//圖片裁剪縱座標開始位置
                 cropWidth,//要裁剪的寬度
cropHeight);//要裁剪的高度

也就是說我們只要指定了被裁減框的開始座標(cropX,cropY )和要裁剪的寬高,就可以實現簡單的裁剪功能(當然實際應用中,cropX,cropY,cropWidth,cropHeight四個值的大小會隨著手指的移動而改變)。

在博主看完cropper實現程式碼之後我不禁想要是我來實現會怎麼寫?,是否會充斥了一大頓的if-else呢?答案是我肯定寫不了這麼優雅的程式碼設計,這也是我為這篇部落格的原因之一吧,畢竟能在研究其程式碼的過程總也靜下來著實思考了一番,也有所體悟。

先來看下面一張裁剪框理論圖:

這裡寫圖片描述
如上圖所示綠色背景假設為待裁剪的圖片,紅色邊框為裁剪框。A,B,C,D四個黑點是手指相對於裁剪框可能出現的位置。那麼A,B,C,D四個黑點周圍的虛圓圈是神馬意思呢?圓圈的意思就是說以手指為圓心,圓圈周邊如果與裁剪框上下左右四條邊任一一條邊相交,則表明手指此時可以對裁剪框進行操作,比如縮放,拉伸,平移等等。

手指位於A 位置:表明手指是對裁剪框的四個角某一個角進行拉伸縮放操作。此時隨著手指的移動,裁剪框的四條邊的長度都會發生變化:要麼同時縮小,要麼同時放大。

手指位於C位置:表明手指操作的是裁剪框的上下左右四條邊的某一條邊,此時隨著手指的左右移動,比如手指在如圖所示C的位置,那麼手指向左移動的時候,上下兩條邊會縮短;向右移動的時候,上下兩條邊被拉長。

手指位於B位置:說明此時手指處於裁剪框的內部,此時隨著手指的移動,裁剪框也會跟著移動。也即是此時是對裁剪框進行拖動操作。

當然當手指位於D位置的時候:都遠離裁剪框了,當然do nothing了.

上面囉嗦了這麼多,無非是想說明手指相對裁剪框可能出現的位置,以及對應的處理情況。

那麼問題來了:既然手指會出現在A,B,C,D四種位置,那麼我們的CropImageView是怎麼判定當前使用者是位於這四種位置關係中的哪一個呢?

先不來解答這個問題,從上圖中我們能看到什麼?博主你這特麼什麼問題!當然是一張圖片(綠色背景),四個手指頭所代表的點,還有一個裁剪框!但是如果從面向物件的角度來說應該這麼回答:一個圖片物件(ImageView),一個裁剪框物件(CropWindow),五個手指按下的位置物件(為同一類的五個例項)。

而構成裁剪框的四個要素如下圖四個邊(LEFT,TOP,BOTTOM,RIGHT)所示:
這裡寫圖片描述

正如上圖所示一個裁剪框有四條邊,在cropper的實現中講這四條邊用一個物件來表示(列舉類):

public enum Edge {
    //對應著上圖中的LEFT,TOP,RIGHT,BOTTOM四條邊
    LEFT,
    TOP,
    RIGHT,
    BOTTOM;

    //上下左右邊界的的座標值,比如LEFT,RIGHT兩條邊的值是對應的邊距離圖片最左邊的距離
    private float mCoordinate;

    //初始化裁剪框時 
    public void initCoordinate(float coordinate) {
        mCoordinate = coordinate;
    }
  }

而裁剪框的基本功能就是隨著手指的移動而動態改變四條邊對應的座標值mCoordinate!!!根據第二幅圖的情況我們知道手指操控的裁剪框的方位:
四個角(對應圖二中的A位置):(LEFT,TOP),(TOP,RIGHT),(RIGNT,BOTTOM),(BOTTOM,LEFT)
四條邊(對應圖二中國的C位置):LEFT,RIGHT,BOTTOM,TOP
中間位置Center

/**
 * 表示手指選中的裁剪框的哪一個邊:有如下幾種情況:
 * 手指選中一條邊的情況:LEFT,TOP,RIGHT,BOTTOM
 * 手指選中兩條邊的情況:此時手指位於裁剪框的四個角度的某一個:LEFT and TOP, TOP and RIGHT, RIGHT and BOTTOM, BOTTOM and RIGHT
 * 手指在裁剪框的中間區域,此時移動手指進行的是平移操作
 */
public enum CropWindowEdgeSelector {

    //////////////對應圖2 A點///////////////////////////

    //左上角:此時是控制裁剪框最上邊和最左邊的兩條邊
    TOP_LEFT(new CropWindowScaleHelper(Edge.TOP, Edge.LEFT)),

    //右上角:此時是控制裁剪框最上邊和最右邊的兩條邊
    TOP_RIGHT(new CropWindowScaleHelper(Edge.TOP, Edge.RIGHT)),

    //左下角:此時是控制裁剪框最下邊和最左邊的兩條邊
    BOTTOM_LEFT(new CropWindowScaleHelper(Edge.BOTTOM, Edge.LEFT)),

    //右下角:此時是控制裁剪框最下邊和最右邊的兩條邊
    BOTTOM_RIGHT(new CropWindowScaleHelper(Edge.BOTTOM, Edge.RIGHT)),

     //////////////對應圖2 C點///////////////////////////

    //僅控制裁剪框左邊線
    LEFT(new CropWindowScaleHelper(null, Edge.LEFT)),

    //僅控制裁剪框右邊線
    TOP(new CropWindowScaleHelper(Edge.TOP, null)),

    //僅控制裁剪框上邊線
    RIGHT(new CropWindowScaleHelper(null, Edge.RIGHT)),

    //僅控制裁剪框下邊線
    BOTTOM(new CropWindowScaleHelper(Edge.BOTTOM, null)),

   //////////////對應圖2 B點///////////////////////////

    //中間位置
    CENTER(new CropWindowMoveHelper());
   }

注意上面這部分程式碼在ACTION_DOWN事件種會根據手指的位置來返回具體的物件,比如手指在裁剪框中間(B位置)就返回上面的CENTER物件。(詳細說明參見下文)

上面囉嗦了這麼多,下面開始講解如何裁剪框的具體實現:

  • 初始化裁剪框的大小,或者說什麼時候初始化?

    縱觀View的繪製流程,在View 的onLayout方法方法裡面比較合適,首先時獲取ImageView的範圍大小,然後用ImageView的返回大小來初始化裁剪框的大小:

 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {

        super.onLayout(changed, left, top, right, bottom);
        //獲取圖片的範圍RectF 
        mBitmapRect = getBitmapRect();
        //初始化裁剪框的大小
        initCropWindow(mBitmapRect);
    }

讓我們看看裁剪框的初始化都做了些什麼:

 /**
     * 初始化裁剪框
     *
     * @param bitmapRect
     */
    private void initCropWindow(@NonNull RectF bitmapRect) {

        //裁剪框距離圖片左右的padding值
        final float horizontalPadding = 0.01f * bitmapRect.width();
        final float verticalPadding = 0.01f * bitmapRect.height();

        //初始化裁剪框上下左右四條邊
        Edge.LEFT.initCoordinate(bitmapRect.left + horizontalPadding);
        Edge.TOP.initCoordinate(bitmapRect.top + verticalPadding);
        Edge.RIGHT.initCoordinate(bitmapRect.right - horizontalPadding);
        Edge.BOTTOM.initCoordinate(bitmapRect.bottom - verticalPadding);
    }
  • 繪製裁剪框:根據圖一我們的裁剪框有三部分內容:九宮格引導線、裁剪邊框、和四個角
    具體的繪製當然是在onDraw方法裡面:
 @Override
    protected void onDraw(Canvas canvas) {

        super.onDraw(canvas);
        //繪製九宮格引導線
        drawGuidelines(canvas);
        //繪製裁剪邊框
        drawBorder(canvas);
        //繪製裁剪邊框的四個角
        drawCorners(canvas);
    }

以繪製裁剪框為例看看時怎麼實現的drawBorder為例,其餘的參考原始碼

 private void drawBorder(@NonNull Canvas canvas) {

        canvas.drawRect(Edge.LEFT.getCoordinate(),
                Edge.TOP.getCoordinate(),
                Edge.RIGHT.getCoordinate(),
                Edge.BOTTOM.getCoordinate(),
                mBorderPaint);
    }

so easy直接呼叫drawRect繪製一個矩形,當然矩形的四個座標值時根據上面說的Edge列舉物件來獲取的。

分析到此為止,裁剪框已經可以出現在介面中了,就差手指移動來拖動或者縮放裁剪框了,因為裁剪框時隨著手指的移動而改變大小的,所以這又牽扯到類事件處理:

  • 在ACTION_DOWN事件中怎麼確認手指所在的位置是圖二中的哪個部位。
    根據上面第二幅圖的分析,我們需要確認手指按下的時候位於A,B,C,D對應的具體哪一種位置,這就需要我們在ACTION_DOWN事件中來判斷,具體的判斷也很簡單,首先獲取裁剪框的座標位置,然後取手指按下的位置與裁剪框位置相匹配:
 @Override
    public boolean onTouchEvent(MotionEvent event) {

        switch (event.getAction()) {

            case MotionEvent.ACTION_DOWN:
               //處理action邏輯
                onActionDown(event.getX(), event.getY());
                return true;

            //省略部分程式碼
        }
    }

來分析分析具體的onActionDown方法:

     /**
     * 判斷手指是否的位置是否在有效的縮放區域:縮放區域的半徑為targetRadius
     * 縮放區域使指:裁剪框的四個角度或者四條邊,當手指位置處在某個角
     * 或者某條邊的時候,則隨著手指的移動對裁剪框進行縮放操作。
     * 如果手指位於裁剪框的內部,則裁剪框隨著手指的移動而只進行移動操作。
     * 否則可以判定手指距離裁剪框較遠而什麼都不做
     */
    public static CropWindowEdgeSelector getPressedHandle(float x,
                                                          float y,
                                                          float left,
                                                          float top,
                                                          float right,
                                                          float bottom,
                                                          float targetRadius) {

        CropWindowEdgeSelector nearestCropWindowEdgeSelector = null;

        //判斷手指距離裁剪框哪一個角最近

        //最近距離預設正無窮大
        float nearestDistance = Float.POSITIVE_INFINITY;
        ////判斷手指是否在圖二的A位置:四個角之一/////////////////

        //計算手指距離左上角的距離
        final float distanceToTopLeft = calculateDistance(x, y, left, top);
        if (distanceToTopLeft < nearestDistance) {
            nearestDistance = distanceToTopLeft;
            nearestCropWindowEdgeSelector = CropWindowEdgeSelector.TOP_LEFT;
        }


        //計算手指距離右上角的距離
        final float distanceToTopRight = calculateDistance(x, y, right, top);
        if (distanceToTopRight < nearestDistance) {
            nearestDistance = distanceToTopRight;
            nearestCropWindowEdgeSelector = CropWindowEdgeSelector.TOP_RIGHT;
        }

        //計算手指距離左下角的距離
        final float distanceToBottomLeft = calculateDistance(x, y, left, bottom);
        if (distanceToBottomLeft < nearestDistance) {
            nearestDistance = distanceToBottomLeft;
            nearestCropWindowEdgeSelector = CropWindowEdgeSelector.BOTTOM_LEFT;
        }

        //計算手指距離右下角的距離
        final float distanceToBottomRight = calculateDistance(x, y, right, bottom);
        if (distanceToBottomRight < nearestDistance) {
            nearestDistance = distanceToBottomRight;
            nearestCropWindowEdgeSelector = CropWindowEdgeSelector.BOTTOM_RIGHT;
        }

        //如果手指選中了一個最近的角,並且在縮放範圍內則返回這個角
        if (nearestDistance <= targetRadius) {
            return nearestCropWindowEdgeSelector;
        }


  ///判斷手指是否在圖二的C位置:四個邊的某條邊/////////////////

        if (CatchEdgeUtil.isInHorizontalTargetZone(x, y, left, right, top, targetRadius)) {
            return CropWindowEdgeSelector.TOP;//說明手指在裁剪框top區域
        } else if (CatchEdgeUtil.isInHorizontalTargetZone(x, y, left, right, bottom, targetRadius)) {
            return CropWindowEdgeSelector.BOTTOM;//說明手指在裁剪框bottom區域
        } else if (CatchEdgeUtil.isInVerticalTargetZone(x, y, left, top, bottom, targetRadius)) {
            return CropWindowEdgeSelector.LEFT;//說明手指在裁剪框left區域
        } else if (CatchEdgeUtil.isInVerticalTargetZone(x, y, right, top, bottom, targetRadius)) {
            return CropWindowEdgeSelector.RIGHT;//說明手指在裁剪框right區域
        }


   ////判斷手指是否在圖二的B位置:裁剪框的中間/////////////////
        if (isWithinBounds(x, y, left, top, right, bottom)) {
            return CropWindowEdgeSelector.CENTER;
        }

  ////手指位於裁剪框的D位置,此時移動手指什麼都不做/////////////
        return null;
    }

OK,ACTION_DOWN事件之後我們就知道此時手指相對於裁剪框的方位,返回一個CropWindowEdgeSelector,在本文中為了方便說明假設手指位於B位置,也就是返回了CENTER物件,那麼此時是時候處理ACTION_MOVE事件來隨著手指的移動對裁剪框進行縮放處理了

  • ACTION_MOVE 事件

    該事件的邏輯也很簡單,就是根據手指的x,y位置來動態修改Edge.LEFT,Edege.RIGHT,Edge.BOTTOM,Edge.TOP的值,注意修改之後別忘了呼叫invalidate方法進行重繪操作。

private void onActionMove(float x, float y) {

        if (mPressedCropWindowEdgeSelector == null) {
            return;
        }

        x += mTouchOffset.x;
        y += mTouchOffset.y;


   //呼叫updateCropWindow方法    
     mPressedCropWindowEdgeSelector.updateCropWindow(x, y, mBitmapRect);
        //別忘了重繪
        invalidate();
    }

在對ACTION_DOWN的講解種我們假設手指位於B位置,即mPressedCropWindowEdgeSelector的具體物件是CENTER這個列舉類,當手指位於B位置的時候是對裁剪框進行的拖動操作,不涉及到縮放,就讓我們看看這個物件的updateCropWindow方法都做了寫什麼:

@Override
    void updateCropWindow(float x,
                          float y,
                          @NonNull RectF imageRect) {

        /*獲取裁剪框的四個座標位置*/
        float left = Edge.LEFT.getCoordinate();
        float top = Edge.TOP.getCoordinate();
        float right = Edge.RIGHT.getCoordinate();
        float bottom = Edge.BOTTOM.getCoordinate();

        /*獲取裁剪框的中心位置*/
        final float currentCenterX = (left + right) / 2;
        final float currentCenterY = (top + bottom) / 2;

        /*判斷手指移動的距離*/
        final float offsetX = x - currentCenterX;
        final float offsetY = y - currentCenterY;

        /*更新裁剪框四條邊的座標*/
        Edge.LEFT.offset(offsetX);
        Edge.TOP.offset(offsetY);
        Edge.RIGHT.offset(offsetX);
        Edge.BOTTOM.offset(offsetY);

        /*/////////////裁剪框越界處理////////////////*/

        /*左邊越界*/
        if (Edge.LEFT.isOutsideMargin(imageRect)) {
            /*獲取此時x越界時的座標位置*/
            float currentCoordinate = Edge.LEFT.getCoordinate();

            /*重新指定左邊的值為初始值*/
            Edge.LEFT.initCoordinate(imageRect.left);

            /*越界的距離*/
            float offset = Edge.LEFT.getCoordinate() - currentCoordinate;

            /*修正最右邊的偏移量*/
            Edge.RIGHT.offset(offset);

       } else if (Edge.RIGHT.isOutsideMargin(imageRect))     {
        /*右邊越界處理邏輯與左邊越界雷同*/

        }


        if (Edge.TOP.isOutsideMargin(imageRect)) {
            /*上邊越界處理邏輯與左邊越界雷同*/

        } else if (Edge.BOTTOM.isOutsideMargin(imageRect)) {

        /*下邊越界處理邏輯與左邊越界雷同*/

        }
    }

上面的程式碼就是常規的處理拖動的邏輯:不斷修改裁剪框的座標值並做臨界越界處理。

當裁剪過後怎麼拿到裁剪的圖片呢?看看getCroppedImage方法:

public Bitmap getCroppedImage() {


        final Drawable drawable = getDrawable();
        if (drawable == null || !(drawable instanceof BitmapDrawable)) {
            return null;
        }

        final float[] matrixValues = new float[9];
        getImageMatrix().getValues(matrixValues);

        final float scaleX = matrixValues[Matrix.MSCALE_X];
        final float scaleY = matrixValues[Matrix.MSCALE_Y];
        final float transX = matrixValues[Matrix.MTRANS_X];
        final float transY = matrixValues[Matrix.MTRANS_Y];

        float bitmapLeft = (transX < 0) ? Math.abs(transX) : 0;
        float bitmapTop = (transY < 0) ? Math.abs(transY) : 0;

        //獲取原圖片
        final Bitmap originalBitmap = ((BitmapDrawable) drawable).getBitmap();

         //獲取裁剪框x,y的座標位置
        final float cropX = (bitmapLeft + Edge.LEFT.getCoordinate()) / scaleX;
        final float cropY = (bitmapTop + Edge.TOP.getCoordinate()) / scaleY;

        //計算裁剪框的寬高
        final float cropWidth = Math.min(Edge.getWidth() / scaleX, originalBitmap.getWidth() - cropX);
        final float cropHeight = Math.min(Edge.getHeight() / scaleY, originalBitmap.getHeight() - cropY);

        //生成裁剪框的bitmap
        return Bitmap.createBitmap(originalBitmap,
                (int) cropX,
                (int) cropY,
                (int) cropWidth,
                (int) cropHeight);

    }

通過上面的方法我們拿到了裁剪的bitmap,也就是createBitmap方法的合理使用而已。然後就可以進行自己的操作了,比如上傳圖片,或者儲存bitmap到本地等等。

當然此自定義裁剪View使用起來也很簡單:

       cropImageView = (CropImageView) findViewById(R.id.cropImageView);
        //設定要裁剪圖片
        cropImageView.setImageResource(R.drawable.timg);

        findViewById(R.id.cropOk).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //獲取裁剪的圖片
                Bitmap cropBitMap = cropImageView.getCroppedImage();
                cropImageView.setImageBitmap(cropBitMap);
            }
        });

到此為止本片博文結束:整個的裁剪功能其實並不難。難的就是如果不進行合理設計可能寫出一堆難以閱讀和維護的程式碼,比如瘋狂的if-else巢狀而已,學習從程式碼設計角度來說cropper庫是很好的教材可以讓我們去體會。