在很多App 中,需要注册登录,那么就免不了 设置用户的头像。头像无非就是方形 或者 圆形,那么就诞生了这样一个需求:
- 从相册中选择一张图片
- 中间区域是圆形 或者 方形的透明裁剪框
- 裁剪框周围是阴影
- 图片可以移动、缩放
网上有很多,包括Github上,但是绝大多数都是 移动裁剪框,而不是移动图片。但是最后还是找到了一个可以参考模仿的例子《Android开发技巧——定制仿微信图片裁剪控件》。这篇博客详细讲述了自定义裁剪控件的过程。以前我也是直接拿过来用的。直到某一天,产品跑过来说:“裁剪中间这个矩形框能不能改成圆的,因为我们的头像是圆的”。 于是我就开始改这个代码了,说不上优化,只是换一种思路去解决问题。
有图有真相
原作者定制内容
- 合并裁剪框的内容到ImageView中
- 裁剪框可以是任意长宽比的矩形
- 裁剪框的左右外边距可以设置
- 遮罩层颜色可以设置
- 裁剪框下有提示文字
- 后面产品又加入了一条裁剪图片的最大大小
我的修改:
- 去掉了底部提示文字(有需要可以自己加)
- 画遮罩和中间的裁剪框是 用到了 PorterDuffXfermode 方法
- 在最后裁剪成头像时 也用到了上述方法
- 圆形框或者矩形框 可以自由选择
- 图片的边框可以滑倒裁剪框内部,松开手指时,会有一个回弹效果
自定义属相修改
<attr name="civHeight" format="integer" />
<attr name="civWidth" format="integer" />
<attr name="civMaskColor" format="color" />
<attr name="civClipPadding" format="dimension" />
<attr name="civClipCircle" format="boolean" />
- civHegiht 和 civWidth 是中间矩形裁剪框的比值
- civMaskColor 是裁剪框外围的颜色,也成遮罩颜色
- civPadding 是裁剪框距离我们控件的边距
其他内容像 参数变量,构造方法等等,原作者都做了详细的描述,我这里就不再一一赘述了,接下来我们就重点讲讲 绘制裁剪框和遮罩、 图片拖动、双击缩放 以及绘制头像。
绘制裁剪框
基本思路:在一个新建的 Canvas 上画一个 遮罩(bitmap),再用一个透明的画笔 设置 PorterDuffXfermode 为PorterDuff.Mode.CLEAR,在遮罩上画一个 矩形或圆形,这样一个中间为空的裁剪框 的遮罩就形成了。将遮罩画在所选图片上之上,别忘了 还要画一个 圆形框或者矩形框。
/**
* 画中间的边框(方形或是圆形)
*/
public void drawRectangleOrCircle(Canvas canvas) {
float cx = mClipBorder.left + mClipBorder.width() / 2f;
float cy = mClipBorder.top + mClipBorder.height() / 2f;
float radius = mClipBorder.height() / 2f;
RectF rectF = new RectF(mClipBorder.left, mClipBorder.top, mClipBorder.right, mClipBorder.bottom);
Bitmap bitmap = Bitmap.createBitmap(canvas.getWidth(), canvas.getHeight(), Bitmap.Config.ARGB_8888);
Canvas temp = new Canvas(bitmap);
Paint transparentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
PorterDuffXfermode porterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
transparentPaint.setColor(Color.TRANSPARENT);
temp.drawRect(0, 0, temp.getWidth(), temp.getHeight(), mPaint);
transparentPaint.setXfermode(porterDuffXfermode);
if (mDrawCircleFlag) { // 画圆
temp.drawCircle(cx, cy, radius, transparentPaint);
} else { // 画矩形(可以设置矩形的圆角)
temp.drawRect(rectF, transparentPaint);
}
canvas.drawBitmap(bitmap, 0, 0, null);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.WHITE);
mPaint.setStrokeWidth(DeviceUtil.dip2px(2));
if (mDrawCircleFlag) {
canvas.drawCircle(cx, cy, radius, mPaint);
} else {
canvas.drawRect(rectF, mPaint);
}
}
底部的遮罩层是目标图像,上面的裁剪形状是源图像,PorterDuff.Mode.CLEAR 起到了清空源图像所在区域图像的作用。
双击缩放
说到双击,我们必要要检测到手指检测,当然啦,我们不会傻到自己写一塌逻辑去检测用户是否是双击了屏幕。这里我们用到了这个GestureDetector 来监听用户手势,如果是双击,自然会走双击的回掉方法 public boolean onDoubleTap(MotionEvent e) 。
手势监听
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;
}
});
大家有没有注意到 这个 setScaleType(ScaleType.MATRIX) ,该方法就是设置 imageview 可以根据 矩阵去缩放。
缩放操作
private class AutoScaleRunnable implements Runnable {
static final float BIGGER = 1.07f;
static final float SMALLER = 0.93f;
private final float mTargetScale;
private final float tmpScale;
/**
* 缩放的中心
*/
private final float x;
private final float y;
/**
* 传入目标缩放值,根据目标值与当前值,判断应该放大还是缩小
*/
AutoScaleRunnable(float targetScale, float x, float y) {
this.mTargetScale = targetScale;
this.x = x;
this.y = y;
if (getScale() < mTargetScale) {
tmpScale = BIGGER;
} else {
tmpScale = SMALLER;
}
}
@Override
public void run() {
// 进行缩放
mScaleMatrix.postScale(tmpScale, tmpScale, x, y);
checkBorder();
setImageMatrix(mScaleMatrix);
final float currentScale = getScale();
// 如果值在合法范围内,继续缩放
if (((tmpScale > 1f) && (currentScale < mTargetScale))
|| ((tmpScale < 1f) && (mTargetScale < currentScale))) {
ClipImageView.this.postDelayed(this, 16);
} else {
// 设置为目标的缩放比例
final float deltaScale = mTargetScale / currentScale;
mScaleMatrix.postScale(deltaScale, deltaScale, x, y);
checkBorder();
setImageMatrix(mScaleMatrix);
isAutoScale = false;
}
}
}
这边是根据 放大倍数(1.07) 和缩小倍数(0.93) 去缩放的,是慢慢缩放,而不是一下子缩放,这样会很突兀。注意缩放完了以后记得去检查边界,因为在缩放后,图片的边界在裁剪框里面,所以需要再去移动图片 以达到适配裁剪框的目的。
两个手指缩放
通过多指操作 来检测缩放,我们用到了这个监听类 ScaleGestureDetector。
@Override
public boolean onScale(ScaleGestureDetector detector) {
float scale = getScale();
float scaleFactor = detector.getScaleFactor();
if (getDrawable() == null)
return true;
//缩放的范围控制
if ((scale < mScaleMax && scaleFactor > 1.0f)
|| (scale > mInitScale && scaleFactor < 1.0f)) {
//缩放阙值最小值判断
if (scaleFactor * scale < mInitScale) {
scaleFactor = mInitScale / scale;
}
if (scaleFactor * scale > mScaleMax) {
scaleFactor = mScaleMax / scale;
}
//设置缩放比例
mScaleMatrix.postScale(scaleFactor, scaleFactor,
detector.getFocusX(), detector.getFocusY());
checkBorder();
setImageMatrix(mScaleMatrix);
}
return true;
}
我们在控制缩放的时候,还是需要计算一下在不在我们的缩放倍数范围里。
移动图片
这边的设计是:
- 如果图片的宽 或 高正好等于裁剪框的宽 或高,那么久移动不了图片
- 放大后,移动图片,图片的边界可以移动到裁剪框里面,手指松开后,图片自动校正适配裁剪框
@Override
public boolean onTouch(View v, MotionEvent event) {
if (mGestureDetector.onTouchEvent(event))
return true;
mScaleGestureDetector.onTouchEvent(event);
float x = 0, y = 0;
// 拿到触摸点的个数
final int pointerCount = event.getPointerCount();
// 得到多个触摸点的x与y均值
for (int i = 0; i < pointerCount; i++) {
x += event.getX(i);
y += event.getY(i);
}
x /= pointerCount;
y /= pointerCount;
//每当触摸点发生变化时,重置mLasX , mLastY
if (pointerCount != lastPointerCount) {
isCanDrag = false;
mLastX = x;
mLastY = y;
}
lastPointerCount = pointerCount;
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
float dx = x - mLastX;
float dy = y - mLastY;
if (!isCanDrag) {
isCanDrag = isCanDrag(dx, dy);
}
if (isCanDrag) {
if (getDrawable() != null) {
RectF rectF = getMatrixRectF();
// 如果宽度小于屏幕宽度,则禁止左右移动
if ((int) rectF.width() <= mClipBorder.width()) {
dx = 0;
}
// 如果高度小于屏幕高度,则禁止上下移动
if ((int) rectF.height() <= mClipBorder.height()) {
dy = 0;
}
// 这里主要是 当宽或者高 大于 裁剪框的高或宽时,移动到与裁剪框边重合时,可以继续移动
if (rectF.left > mClipBorder.left + 1 || rectF.top > mClipBorder.top + 1 || rectF.right < mClipBorder.right - 1 || rectF.bottom < mClipBorder.bottom - 1) {
dx = dx * 0.25f;
dy = dy * 0.25f;
}
mScaleMatrix.postTranslate(dx, dy);
setImageMatrix(mScaleMatrix);
}
}
mLastX = x;
mLastY = y;
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
lastPointerCount = 0;
// 当抬起手指时,如果划过了,没有填满裁剪框,就要自动弹回
checkBorder();
setImageMatrix(mScaleMatrix);
break;
}
return true;
}
裁剪头像
原著作者的思路:在矩阵中获取 图片的 缩放倍数,移动距离,进行相除和相加减,最后在原图上进行裁剪,这样有一个不好的地方,float 和 int 相互转换,会有1 px 的误差,而这个误差很有可能在裁剪时 崩溃,因为超出了图片的宽度,这是在实践中验证过了。
作为新时代的青年,我们当然要换一种想法,那就是 PorterDuffXfermode,只不过这里的model 是PorterDuff.Mode.SRC_IN。看上去还不错哦,与开头画裁剪框和遮罩 形成了 首尾呼应。
先在 新建的Canvas 上 画一个 透明的 Bitmap,中间画一个 填充型矩形,设置画笔 的Xfermode ,接着将变化后的bitmap 画上去,这样两个Bitmap 一合成,就在画布上形成了 周围是透明,只有中间矩形里 是图片,最后将矩形图片截图出来。
/**
* 截图操作
*/
public Bitmap clip() {
final Drawable drawable = getDrawable();
final Bitmap originalBitmap = ((BitmapDrawable) drawable).getBitmap();
Bitmap bottomBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_4444);
Canvas canvas = new Canvas(bottomBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
canvas.drawRect(mClipBorder, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(originalBitmap, mScaleMatrix, paint);
return Bitmap.createBitmap(bottomBitmap, (int) mClipBorder.left, (int) mClipBorder.top, (int) mClipBorder.width(), (int) mClipBorder.height());
}
效果演示
截图控件