天天看點

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

1.項目背景:

很多項目中用到的頭像都是圓頭像,圓頭像使界面看起來更具有美感,比如QQ在整個應用中使用的就是圓頭像。基本上每個應用都有上傳頭像的功能,在上傳頭像的時候需要對圖檔裁剪。雖然截取出來的圖檔實際上都是方圖,但是在裁剪的時候如果能直覺的讓使用者看到裁剪以後的圓頭像的效果,體驗就更好了。一像注重使用者體驗的QQ就是這麼實作的,如圖

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

我們項目中用到的也是圓頭像。截取頭像功能的需求就是做成跟QQ類似的互動,一般我們在設計軟體的時候都習慣向業界知名産品“學習”(你懂得)。

我試着上網查了一下開源的頭像截取的項目,發現這些項目的截圖界面全部都是方圖,好吧,輪子還沒人造,隻能自己動手了。

2.效果圖:

csdn圖檔限制竟然才2M,隻好将效果圖分成兩份了,前後連起來看。

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ
仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

想着其他人做項目的時候可能用的着,就把截圖的功能整理了一下,做成了一個小demo,沒必要重複造輪子了,分享是網際網路的精神精髓。

3.項目托管位址:

https://github.com/ShuangtaoJia/ClipCircleHeadLikeQQ

如果還不會使用github,你就真out了

4.使用方法:

  • 打開截圖界面
/**
     * 打開截圖界面
     * @param uri 原圖的Uri
     */
    public void starCropPhoto(Uri uri) {

        if (uri == null) {
            return;
        }
        Intent intent = new Intent();
        intent.setClass(this, ClipHeaderActivity.class);
        intent.setData(uri);
        intent.putExtra("side_length", 200);//裁剪圖檔寬高
        startActivityForResult(intent, CROP_PHOTO);
    }
           
  • 擷取傳回結果

在onActivityResult 方法中

Uri uri = intent.getData();

  • 上傳頭像

可以通過uri得到截圖檔案的本地路徑,可以友善的上傳到伺服器

  • 頭像展示

iv_head_icon.setImageURI(uri);   ImageView 提供有直接設定圖檔URI的方法,可以友善的在ImageView中展示

5.實作原理:

下面簡單介紹一下具體實作原理:

  • 項目結構:

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ
仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

  • 編譯方式:

本項目采用的是gradle編譯 導入的時候選gradle wrapper 一般沒什麼問題

  • 截圖界面 ClipHeaderActivity
package com.example.clipheaderlikeqq.app.clip;

import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnTouchListener;
import android.view.ViewTreeObserver;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import com.example.clipheaderlikeqq.app.R;
import com.example.clipheaderlikeqq.app.util.BitmapUtil;
import com.example.clipheaderlikeqq.app.util.CommonUtil;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;


/**
 * 圖檔裁剪activity
 *

 使用方法:
 intent.setData(uri);//源圖檔Uri
 intent.putExtra("side_length", 130);//裁剪圖檔寬高

 */
public class ClipHeaderActivity extends Activity implements OnTouchListener{
    private String TAG = "ClipHeaderActivity";
    private ImageView srcPic;
    private ImageView iv_back;
    private View bt_ok;
    private ClipView clipview;

    private Matrix matrix = new Matrix();
    private Matrix savedMatrix = new Matrix();

    /**
     * 動作标志:無
     */
    private static final int NONE = 0;
    /**
     * 動作标志:拖動
     */
    private static final int DRAG = 1;
    /**
     * 動作标志:縮放
     */
    private static final int ZOOM = 2;
    /**
     * 初始化動作标志
     */
    private int mode = NONE;

    /**
     * 記錄起始坐标
     */
    private PointF start = new PointF();
    /**
     * 記錄縮放時兩指中間點坐标
     */
    private PointF mid = new PointF();
    private float oldDist = 1f;

    private Bitmap bitmap;

    private int side_length;//裁剪區域邊長

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.clip_activity);
        init();

    }

    private void init() {
        side_length = getIntent().getIntExtra("side_length",200);

        iv_back = (ImageView) findViewById(R.id.iv_back);
        srcPic = (ImageView) findViewById(R.id.src_pic);
        clipview =  (ClipView)findViewById(R.id.clipView);
        bt_ok = findViewById(R.id.bt_ok);

        srcPic.setOnTouchListener(this);

        //clipview中有初始化原圖所需的參數,是以需要等到clipview繪制完畢再初始化原圖
        ViewTreeObserver observer = clipview.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {

            @SuppressWarnings("deprecation")
            public void onGlobalLayout() {
                clipview.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                initSrcPic();
            }
        });

        bt_ok.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                generateUriAndReturn();
            }
        });

        iv_back.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                finish();
            }
        });
    }


    /**
     * 初始化圖檔
     * step 1: decode 出 720*1280 左右的照片  因為原圖可能比較大 直接加載出來會OOM
     * step 2: 将圖檔縮放 移動到imageView 中間
     */
    private void initSrcPic(){
        Uri uri = getIntent().getData();
        String path = CommonUtil.getRealFilePathFromUri(getApplicationContext(), uri);
        if (TextUtils.isEmpty(path)) {
            return;
        }
        //原圖可能很大,現在手機照出來都3000*2000左右了,直接加載可能會OOM
        //這裡 decode 出 720*1280 左右的照片
        bitmap = BitmapUtil.decodeSampledBitmap(path,720,1280);

        if (bitmap == null) {
            return;
        }

        
        //圖檔的縮放比
        float scale ;
        if (bitmap.getWidth()>bitmap.getHeight()) {//寬圖
            scale = (float)srcPic.getWidth()/bitmap.getWidth();

            //如果高縮放後小于裁剪區域 則将裁剪區域與高的縮放比作為最終的縮放比
            Rect rect = clipview.getClipRect();
            float minScale = rect.height()/bitmap.getHeight();//高的最小縮放比
            if (scale < minScale){
                scale = minScale;
            }
        }else {//高圖
            scale = (float)srcPic.getWidth()/2/bitmap.getWidth();//寬縮放到imageview的寬的1/2
        }

        // 縮放
        matrix.postScale(scale, scale);

        // 平移   将縮放後的圖檔平移到imageview的中心
        int midX = srcPic.getWidth()/2;//imageView的中心x
        int midY = srcPic.getHeight()/2;//imageView的中心y
        int imageMidX = (int)(bitmap.getWidth()*scale/2);//bitmap的中心x
        int imageMidY = (int)(bitmap.getHeight()*scale/2);//bitmap的中心y
        matrix.postTranslate(midX - imageMidX, midY - imageMidY);

        srcPic.setScaleType(ScaleType.MATRIX);
        srcPic.setImageMatrix(matrix);
        srcPic.setImageBitmap(bitmap);

    }

    public boolean onTouch(View v, MotionEvent event) {
        ImageView view = (ImageView) v;
        switch (event.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                savedMatrix.set(matrix);
                // 設定開始點位置
                start.set(event.getX(), event.getY());
                mode = DRAG;
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                oldDist = spacing(event);
                if (oldDist > 10f) {
                    savedMatrix.set(matrix);
                    midPoint(mid, event);
                    mode = ZOOM;
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                mode = NONE;
                break;
            case MotionEvent.ACTION_MOVE:
                if (mode == DRAG) {
                    matrix.set(savedMatrix);
                    matrix.postTranslate(event.getX() - start.x, event.getY()
                            - start.y);
                } else if (mode == ZOOM) {
                    float newDist = spacing(event);
                    if (newDist > 10f) {
                        matrix.set(savedMatrix);
                        float scale = newDist / oldDist;
                        matrix.postScale(scale, scale, mid.x, mid.y);
                    }
                }
                break;
        }
        view.setImageMatrix(matrix);
        return true;
    }

    /**
     * 多點觸控時,計算最先放下的兩指距離
     *
     * @param event
     * @return
     */
    private float spacing(MotionEvent event) {
        float x = event.getX(0) - event.getX(1);
        float y = event.getY(0) - event.getY(1);
        return (float) Math.sqrt(x * x + y * y);
    }

    /**
     * 多點觸控時,計算最先放下的兩指中心坐标
     *
     * @param point
     * @param event
     */
    private void midPoint(PointF point, MotionEvent event) {
        float x = event.getX(0) + event.getX(1);
        float y = event.getY(0) + event.getY(1);
        point.set(x / 2, y / 2);
    }


    /**
     * 擷取縮放後的截圖
     * 1.截取裁剪框内bitmap
     * 2.将bitmap縮放到寬高為side_length
     *
     * @return
     */
    private Bitmap getZoomedCropBitmap() {

        srcPic.setDrawingCacheEnabled(true);
        srcPic.buildDrawingCache();

        Rect rect = clipview.getClipRect();

        Bitmap cropBitmap = null;
        Bitmap zoomedCropBitmap = null;
        try {
            cropBitmap = Bitmap.createBitmap(srcPic.getDrawingCache(), rect.left, rect.top, rect.width(), rect.height());
            zoomedCropBitmap = BitmapUtil.zoomBitmap(cropBitmap,side_length,side_length);
        } catch (Exception e) {
            e.printStackTrace();
        }

        if (cropBitmap != null) {
            cropBitmap.recycle();
        }

        // 釋放資源
        srcPic.destroyDrawingCache();

        return zoomedCropBitmap;
    }


    /**
     * 生成Uri并且通過setResult傳回給打開的activity
     */
    private void generateUriAndReturn(){
        Bitmap zoomedCropBitmap = getZoomedCropBitmap();
        if (zoomedCropBitmap == null) {
            Log.e(TAG, "zoomedCropBitmap == null");
            return;
        }

        Uri mSaveUri = Uri.fromFile(new File(getCacheDir(), "cropped_"+System.currentTimeMillis()+".jpg"));

        if (mSaveUri != null) {
            OutputStream outputStream = null;
            try {
                outputStream = getContentResolver().openOutputStream(mSaveUri);
                if (outputStream != null) {
                    zoomedCropBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream);
                }
            } catch (IOException ex) {
                // TODO: report error to caller
                Log.e(TAG, "Cannot open file: " + mSaveUri, ex);
            } finally {
                if (outputStream != null) {
                    try {
                        outputStream.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }

            Intent intent = new Intent();
            intent.setData(mSaveUri);
            setResult(RESULT_OK, intent);
            finish();
        }
    }

}
           

大部分相關的解釋都在注釋中,不再重複說。

實作截圖的關鍵代碼 cropBitmap = Bitmap.createBitmap(srcPic.getDrawingCache(), rect.left, rect.top, rect.width(), rect.height());

原理:生成ImageView的目前的DrawingCache,相當于此時ImageView的截圖,然後通過ClipView(下面介紹)擷取到镂空的圓的矩形坐标,在ImageView的截圖上按照坐标截取出一個bitmap

PS:這是我想到的一種實作方式,因為我覺得如果根據使用者目前的移動,縮放的情況計算出目前的截圖區域在原圖中對應的坐标,然後真正從原圖中截取出來圖檔比較複雜。如果有更好的實作方式還請下面留言。

  • 帶圓形镂空的遮罩View   ClipView
package com.example.clipheaderlikeqq.app.clip;

import android.content.Context;
import android.graphics.*;
import android.graphics.Paint.Style;
import android.util.AttributeSet;
import android.view.View;

public class ClipView extends View {

    private Paint paint = new Paint();
    /**
     * 畫裁剪區域邊框的畫筆
     */
    private Paint borderPaint = new Paint();

    /**
     * 裁剪框邊框寬度
     */
    private int clipBorderWidth = 2;

    private static final int LAYER_FLAGS = Canvas.MATRIX_SAVE_FLAG | Canvas.CLIP_SAVE_FLAG
            | Canvas.HAS_ALPHA_LAYER_SAVE_FLAG | Canvas.FULL_COLOR_LAYER_SAVE_FLAG
            | Canvas.CLIP_TO_LAYER_SAVE_FLAG;

    private float radiusWidthRatio  = 2f/9;//裁剪圓框的半徑占view的寬度的比

    int width;
    int height;

    private Xfermode xfermode;


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

    public ClipView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ClipView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        paint.setAntiAlias(true); //去鋸齒

        borderPaint.setStyle(Style.STROKE);
        borderPaint.setColor(Color.WHITE);
        borderPaint.setStrokeWidth(clipBorderWidth);
        borderPaint.setAntiAlias(true); //去鋸齒

        xfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_OUT);
    }

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

        //通過Xfermode的DST_OUT來産生中間的圓,一定要另起一個Layer(層),
        // 直接在canvas原本的那一層來做的話,最後中間那個圓空了以後,不是透明的,而是黑色的,
        //我覺得這應該是因為canvas預設在被“掏空”以後,下面是黑色的,而另起一層的話,被“掏空”就是透明的,
        // 然後再把這層加到canvas上就滿足了我們的需求

        //saveLayer相當于新入棧一個圖層,接下來的操作都會在該圖層上進行
        canvas.saveLayer(0, 0, width, height, null, LAYER_FLAGS);
        canvas.drawColor(Color.parseColor("#a8000000"));
        paint.setXfermode(xfermode);
        //中間的透明的圓
        canvas.drawCircle(width / 2, height / 2, width * radiusWidthRatio, paint);

        //白色的圓邊框
        canvas.drawCircle(width / 2, height / 2, width * radiusWidthRatio + clipBorderWidth, borderPaint);
        //出棧,恢複到之前的圖層,意味着建立的圖層會被删除,建立圖層上的内容會被繪制到canvas (or the previous layer)
        canvas.restore();

    }

    /**
     * 擷取裁剪區域的Rect
     * @return
     */
    public Rect getClipRect(){
        Rect rect = new Rect();
        rect.left = (int)(width/2 - width * radiusWidthRatio);//寬度的一半 - 圓的半徑
        rect.right = (int)(width/2 + width * radiusWidthRatio);//寬度的一半 + 圓的半徑
        rect.top = (int)(height/2 - width * radiusWidthRatio);//高度的一半 - 圓的半徑
        rect.bottom = (int)(height/2 + width * radiusWidthRatio);//高度的一半 + 圓的半徑

        return rect;

    }

}
           

這是比較花時間實作的一個自定義view,這個View中間有一個圓形的镂空,讓使用者能夠直覺的看到圓形頭像的效果。 幾點說明: 1.中間镂空的圓是通過 Xfermode的DST_OUT模式實作的。 不了解的可以百度 2.不能直接使用onDraw(Canvas canvas)的canvas對象來實作Xfermode的DST_OUT模式 而需要另起一層canvas.saveLayer來實作,具體原因代碼中解釋過了,canvas.saveLayer不熟的可以參考http://blog.csdn.net/lonelyroamer/article/details/8264189 3.布局中使用ClipView時,ClipView往下不能是空背景 本例中ClipView往下是ImageVIew,ImageView往下就是LinearLayout,也就是ImageVIew和LinearLayout的背景必須手動設定一個,不能不設定,不能讓ClipView往下是空背景

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical"
              android:background="@android:color/black">
           

否則在魅族等神機和模拟器上會出現拖動圖檔的時候在圓型透明區域内重影的現象,猜測原因:圓形區域以下沒有需要繪制的東西,連背景也沒有,是以系統對這部分沒有進行重繪,導緻出現了異常情況。

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

異常情況:

仿QQ圓頭像裁剪開源項目——ClipCircleHeadLikeQQ

6.結尾

有什麼問題,可以下面留言。