1. 程式人生 > >Android開發技巧——定製仿微信圖片裁剪控制元件

Android開發技巧——定製仿微信圖片裁剪控制元件

拍照——裁剪,或者是選擇圖片——裁剪,是我們設定頭像或上傳圖片時經常需要的一組操作。上篇講了Camera的使用,這篇講一下我對圖片裁剪的實現。

背景

  1. 下面的需求都來自產品。
  2. 裁剪圖片要像微信那樣,拖動和放大的是圖片,裁剪框不動。
  3. 裁剪框外的內容要有半透明黑色遮罩。
  4. 裁剪框下面要顯示一行提示文字(這點我至今還是持保留意見的)。

在Android中,裁剪圖片的控制元件庫還是挺多的,特別是github上比較流行的幾個,都已經進化到比較穩定的階段,但比較遺憾的是它們的裁剪過程是拖動或縮放裁剪框,於是只好自己再找,看有沒有現成或半成品的輪子,可以不必從零開始。
踏破鐵鞋無覓處,皇天不負苦心人。我終於找到了兩篇部落格:

《Android高仿微信頭像裁剪》《Android 高仿微信頭像擷取 打造不一樣的自定義控制元件》,以及csdn上找到的前面部落格所對應的一份程式碼,並最終實現了自己的裁剪控制元件。

大神的實現過程

首先先了解一下上面的高仿微信裁剪控制元件的實現過程。說起來也不難,主要是下面幾點:
1,重寫ImageView,並監聽手勢事件,包括雙點,兩點縮放,拖動,使它成為一個實現縮放拖動圖片功能的控制元件。
2,定義一個Matrix成員變數,對於維護該圖片的縮放、平移等矩陣資料。
3,拖動或縮放時,圖片與裁剪框的相交面積一定與裁剪框相等。即圖片不能拖離裁剪框。
3,在設定圖片時,先根據圖片的大小進行初始化的縮放平移操作,使得上面第三條的條件下圖片儘可能的小。
4,每次接收到相對應的手勢事件,都進行對應的矩陣計算,並將計算結果通過ImageView

setImageMatrix方法應用到圖片上。
5,裁剪框是一個單獨的控制元件,與ImageView同樣大,疊加到它上面顯示出來。
6,用一個XXXLayout把裁剪框和縮放封裝起來。
7,裁剪時,先建立一個空的Bitmap並用其建立一個Canvas,把縮放平移後的圖片畫到這個Bitmap上,並建立在裁剪框內的Bitmap(通過呼叫Bitmap.createBitmap方法)。

我的定製內容

我拿到的程式碼是鴻洋大神版本之後再被改動的,程式碼上有點亂(雖然功能上是實現的裁剪)。在原有的功能上,我希望進行的改動有:

  • 合併裁剪框的內容到ImageView中
  • 裁剪框可以是任意長寬比的矩形
  • 裁剪框的左右外邊距可以設定
  • 遮罩層顏色可以設定
  • 裁剪框下有提示文字(自己的產品需求)
  • 後面產品又加入了一條裁剪圖片的最大大小

屬性定義

在上面的功能需求中,我定義了以下屬性:

<declare-styleable name="ClipImageView">
    <attr name="civHeight" format="integer"/>
    <attr name="civWidth" format="integer"/>
    <attr name="civTipText" format="string"/>
    <attr name="civTipTextSize" format="dimension"/>
    <attr name="civMaskColor" format="color"/>
    <attr name="civClipPadding" format="dimension"/>
</declare-styleable>

其中:

  • civHeightcivWidth是裁剪框的寬高比例。
  • civTipText提示文字的內容
  • civTipTextSize提示文字的大小
  • civMaskColor遮罩層的顏色值
  • civClipPadding裁剪內邊距。由於裁剪框是在控制元件內部的,最終我選擇使用padding來說明裁剪框與我們控制元件邊緣的距離。

成員變數

成員變數我進行了一些改動,把原本用於定義裁剪框的水平邊距變數及其他沒什麼用的變數等給去掉了,並加入了自己的一些成員變數,最終如下:

    private final int mMaskColor;//遮罩層顏色

    private final Paint mPaint;//畫筆
    private final int mWidth;//裁剪框寬的大小(從屬性上讀到的整型值)
    private final int mHeight;//裁剪框高的大小(同上)
    private final String mTipText;//提示文字
    private final int mClipPadding;//裁剪框相對於控制元件的內邊距

    private float mScaleMax = 4.0f;//圖片最大縮放大小
    private float mScaleMin = 2.0f;//圖片最小縮放大小

    /**
     * 初始化時的縮放比例
     */
    private float mInitScale = 1.0f;

    /**
     * 用於存放矩陣
     */
    private final float[] mMatrixValues = new float[9];

    /**
     * 縮放的手勢檢查
     */
    private ScaleGestureDetector mScaleGestureDetector = null;
    private final Matrix mScaleMatrix = new Matrix();

    /**
     * 用於雙擊
     */
    private GestureDetector mGestureDetector;
    private boolean isAutoScale;

    private float mLastX;
    private float mLastY;

    private boolean isCanDrag;
    private int lastPointerCount;

    private Rect mClipBorder = new Rect();//裁剪框
    private int mMaxOutputWidth = 0;//裁剪後的圖片的最大輸出寬度

構造方法

構造方法裡主要是多了一些我們自定義屬性的讀取:


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

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

        setScaleType(ScaleType.MATRIX);
        mGestureDetector = new GestureDetector(context,
                new SimpleOnGestureListener() {
                    @Override
                    public boolean onDoubleTap(MotionEvent e) {
                        if (isAutoScale)
                            return true;

                        float x = e.getX();
                        float y = e.getY();
                        if (getScale() < mScaleMin) {
                            ClipImageView.this.postDelayed(new AutoScaleRunnable(mScaleMin, x, y), 16);
                        } else {
                            ClipImageView.this.postDelayed(new AutoScaleRunnable(mInitScale, x, y), 16);
                        }
                        isAutoScale = true;

                        return true;
                    }
                });
        mScaleGestureDetector = new ScaleGestureDetector(context, this);
        this.setOnTouchListener(this);

        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(Color.WHITE);

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ClipImageView);
        mWidth = ta.getInteger(R.styleable.ClipImageView_civWidth, 1);
        mHeight = ta.getInteger(R.styleable.ClipImageView_civHeight, 1);
        mClipPadding = ta.getDimensionPixelSize(R.styleable.ClipImageView_civClipPadding, 0);
        mTipText = ta.getString(R.styleable.ClipImageView_civTipText);
        mMaskColor = ta.getColor(R.styleable.ClipImageView_civMaskColor, 0xB2000000);
        final int textSize = ta.getDimensionPixelSize(R.styleable.ClipImageView_civTipTextSize, 24);
        mPaint.setTextSize(textSize);
        ta.recycle();

        mPaint.setDither(true);
    }

定義裁剪框

裁剪框的位置

裁剪框是在控制元件正中間的,首先我們從屬性中讀取到的是寬高的比例,以及左右邊距,但是在構造方法中,由於控制元件還沒有繪製出來,無法獲取到控制元件的寬高,所以並不能計算裁剪框的大小和位置。所以我重寫了onLayout方法,在這裡計算裁剪框的位置:

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        final int width = getWidth();
        final int height = getHeight();
        mClipBorder.left = mClipPadding;
        mClipBorder.right = width - mClipPadding;
        final int borderHeight = mClipBorder.width() * mHeight / mWidth;
        mClipBorder.top = (height - borderHeight) / 2;
        mClipBorder.bottom = mClipBorder.top + borderHeight;
    }

繪製裁剪框

這裡我順便把繪製提示文字的程式碼也一併給出,都是在同一個方法裡的。很簡單,重寫onDraw方法即可。繪製裁剪框有兩種方法,一是繪製一個滿屏的遮罩層,然後從中間摳出一個長方形出來,但是我用的時候發現摳不出來,所以我採用的是下面這一種:
這裡寫圖片描述
先畫上下兩個矩形,再畫左右兩個矩形,中間所圍起來的沒有畫的部分就是我們的裁剪框。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        final int width = getWidth();
        final int height = getHeight();

        mPaint.setColor(mMaskColor);
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawRect(0, 0, width, mClipBorder.top, mPaint);
        canvas.drawRect(0, mClipBorder.bottom, width, height, mPaint);
        canvas.drawRect(0, mClipBorder.top, mClipBorder.left, mClipBorder.bottom, mPaint);
        canvas.drawRect(mClipBorder.right, mClipBorder.top, width, mClipBorder.bottom, mPaint);

        mPaint.setColor(Color.WHITE);
        mPaint.setStrokeWidth(1);
        mPaint.setStyle(Paint.Style.STROKE);
        canvas.drawRect(mClipBorder.left, mClipBorder.top, mClipBorder.right, mClipBorder.bottom, mPaint);

        if (mTipText != null) {
            final float textWidth = mPaint.measureText(mTipText);
            final float startX = (width - textWidth) / 2;
            final Paint.FontMetrics fm = mPaint.getFontMetrics();
            final float startY = mClipBorder.bottom + mClipBorder.top / 2 - (fm.descent - fm.ascent) / 2;
            mPaint.setStyle(Paint.Style.FILL);
            canvas.drawText(mTipText, startX, startY, mPaint);
        }
    }

修改圖片的初始顯示

這裡我不使用全域性佈局的監聽(通過getViewTreeObserver加入回撥),而是直接重寫幾個設定圖片的方法,在設定圖片後進行初始顯示的設定:

    @Override
    public void setImageDrawable(Drawable drawable) {
        super.setImageDrawable(drawable);
        postResetImageMatrix();
    }

    @Override
    public void setImageResource(int resId) {
        super.setImageResource(resId);
        postResetImageMatrix();
    }

    @Override
    public void setImageURI(Uri uri) {
        super.setImageURI(uri);
        postResetImageMatrix();
    }

    private void postResetImageMatrix() {
        post(new Runnable() {
            @Override
            public void run() {
                resetImageMatrix();
            }
        });
    }

resetImageMatrix()方法設定圖片的初始縮放及平移,參考圖片大小,控制元件本身大小,以及裁剪框的大小進行計算:

    /**
     * 垂直方向與View的邊矩
     */
    public void resetImageMatrix() {
        final Drawable d = getDrawable();
        if (d == null) {
            return;
        }

        final int dWidth = d.getIntrinsicWidth();
        final int dHeight = d.getIntrinsicHeight();

        final int cWidth = mClipBorder.width();
        final int cHeight = mClipBorder.height();

        final int vWidth = getWidth();
        final int vHeight = getHeight();

        final float scale;
        final float dx;
        final float dy;

        if (dWidth * cHeight > cWidth * dHeight) {
            scale = cHeight / (float) dHeight;
        } else {
            scale = cWidth / (float) dWidth;
        }

        dx = (vWidth - dWidth * scale) * 0.5f;
        dy = (vHeight - dHeight * scale) * 0.5f;

        mScaleMatrix.setScale(scale, scale);
        mScaleMatrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f));

        setImageMatrix(mScaleMatrix);

        mInitScale = scale;
        mScaleMin = mInitScale * 2;
        mScaleMax = mInitScale * 4;
    }

注意:這裡有一個坑。把一個Bitmap設定到ImageView中,顯示時要計算的是ImageView獲取的Drawable物件以及這個物件的寬高,而不是Bitmap物件。Drawable物件可能由於對Bitmap的放大或縮小顯示,導致它的寬或高與Bitmap的寬高不同。
還有一點小注意:獲取控制元件寬高是要在控制元件被繪製出來之後才能獲取得到的,所以上面我通過post一個Runnable物件到主執行緒的Looper中,保證它是在介面繪製完成之後被呼叫。

縮放及拖動

縮放及拖動時都需求判斷是否超出邊界,如果超出,則取允許的最終值。這裡的程式碼我沒怎麼動,稍後可直接參考原始碼,暫不贅述。

裁剪

這裡是另外一個改造的重點了。
首先,鴻洋大神是通過建立一個空的Bitmap,並根據它創建出一個Canvas物件,然後通過draw方法把縮放後的圖片給繪製到這個Bitmap中,再呼叫Bitmap.createBitmap得到屬於裁剪框的內容。但是我們已經重寫了onDraw方法畫出裁剪框,所以這裡就不考慮了。
另外,這種方法還有一個問題:它繪製的是Drawable物件。如果我們設定進去的是一個比較大的Bitmap,那麼就可能被縮放了,這裡裁剪的是縮放後的Bitmap,也就是它不是對原圖進行裁剪的。
這裡我參考了其他裁剪圖片庫,通過儲存了縮放平移的Matrix成員變數進行計算,獲取出裁剪框在其的對應範圍,並根據最終所需(我們產品要限制一個最大大小),得到最終的圖片,程式碼如下:

    public Bitmap clip() {
        final Drawable drawable = getDrawable();
        final Bitmap originalBitmap = ((BitmapDrawable) drawable).getBitmap();

        final float[] matrixValues = new float[9];
        mScaleMatrix.getValues(matrixValues);
        final float scale = matrixValues[Matrix.MSCALE_X] * drawable.getIntrinsicWidth() / originalBitmap.getWidth();
        final float transX = matrixValues[Matrix.MTRANS_X];
        final float transY = matrixValues[Matrix.MTRANS_Y];

        final float cropX = (-transX + mClipBorder.left) / scale;
        final float cropY = (-transY + mClipBorder.top) / scale;
        final float cropWidth = mClipBorder.width() / scale;
        final float cropHeight = mClipBorder.height() / scale;

        Matrix outputMatrix = null;
        if (mMaxOutputWidth > 0 && cropWidth > mMaxOutputWidth) {
            final float outputScale = mMaxOutputWidth / cropWidth;
            outputMatrix = new Matrix();
            outputMatrix.setScale(outputScale, outputScale);
        }

        return Bitmap.createBitmap(originalBitmap,
                (int) cropX, (int) cropY, (int) cropWidth, (int) cropHeight,
                outputMatrix, false);
    }

由於我們是對Bitmap進行裁剪,所以首先獲取這個Bitmap

        final Drawable drawable = getDrawable();
        final Bitmap originalBitmap = ((BitmapDrawable) drawable).getBitmap();

然後,我們的矩陣值可以通過一個包含9個元素的float陣列讀出:

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

比如,讀X上的縮放值,程式碼為matrixValues[Matrix.MSCALE_X]
要特別注意一點,在前文也有提到,這裡縮放的是Drawable物件,但是我們裁剪時用的Bitmap,如果圖片太大的話是可能在Drawable上進行縮放的,所以縮放大小的計算應該為:

        final float scale = matrixValues[Matrix.MSCALE_X] * drawable.getIntrinsicWidth() / originalBitmap.getWidth();

然後獲取圖片平移量:

        final float transX = matrixValues[Matrix.MTRANS_X];
        final float transY = matrixValues[Matrix.MTRANS_Y];

計算裁剪框對應在圖片上的起點及寬高:

        final float cropX = (-transX + mClipBorder.left) / scale;
        final float cropY = (-transY + mClipBorder.top) / scale;
        final float cropWidth = mClipBorder.width() / scale;
        final float cropHeight = mClipBorder.height() / scale;

上面就是我們所要裁剪出來的最終結果。
但是,我前面也說的,應產品需求,要限制最大輸出大小。由於我們裁剪出來的圖片寬高比是3:2,我這裡只取寬度(你要取高度也可以)進行限制,所以又加上了如下程式碼,當裁剪出來的寬度超出我們最大寬度時,進行縮放。

        Matrix outputMatrix = null;
        if (mMaxOutputWidth > 0 && cropWidth > mMaxOutputWidth) {
            final float outputScale = mMaxOutputWidth / cropWidth;
            outputMatrix = new Matrix();
            outputMatrix.setScale(outputScale, outputScale);
        }

最終根據上面計算出來的值,建立裁剪出來的Bitmap:

Bitmap.createBitmap(originalBitmap,
                (int) cropX, (int) cropY, (int) cropWidth, (int) cropHeight,
                outputMatrix, false);

這樣,圖片裁剪控制元件就算全部完成。

實現效果

這裡寫圖片描述

後述

  1. 我在控制元件中還增加了一個介面getClipMatrixValues,獲取裁剪時圖片的矩陣值,它可用於做大圖的裁剪。
  2. 有關大圖的裁剪,我後續會再寫一篇。
  3. 大圖裁剪的程式碼,也在上面的demo裡。
  4. 使用時可以設定裁剪框的寬高比來決定是正方形的裁剪框還是有其他比例要求的裁剪框

參考資料: