前言
头像选择控件大概是现在90%以上的App必备的控件了吧。实用性还是蛮高的,记得以前在做毕业设计的时候也需要用到头像选择,然而当时时间比较赶,就没有去研究怎么实现了,直接选择完图片后设置回去了,不能缩放,移动,可以说毫无体验性可言。所以现在想来填填以前的坑,也希望这篇文章能给你带来一些思路。先上一张动态效果图。
功能点
界面是仿国内某读书App的。实现的功能点有:
- 从相册中选择图片。
- 加载图片,突出显示裁剪区域,不裁剪的区域加一层蒙层。
- 手势操作功能:移动,双指缩放,双击缩放。
- 手势操作后调整图片的位置,使得圆形裁剪框中始终有图片内容。
- 点击预览只显示图片的圆形裁剪区域,进入预览状态后不能编辑图片。
- 点击确定裁剪图片并返回结果。
实现思路
以前会觉得这个控件要做的事情很多,实现起来很麻烦,但实际上将它拆分成上面那一个个独立的任务后,再一个个去实现,现在回过头来看,好像也不难。
加载图片
万事开头难,要实现这一系列的需求,首先得将图片显示出来,后面的操作就好办了。从相册中去挑选图片这一步骤就不说了。
从图库挑选图片后,我们会得到一个Uri,然后我们根据这个Uri去获取Bitmap资源。我这里用的实现方式是AsyncTask。加载图片的操作是在ImageCropActivity中去操作的。加载图片的思路是先去获取图片的大小,然后根据需要对图片进行采样缩放,最后将得到的Bitmap设置给我们今天的主角ImageCropView。
private class LoadImageAsyncTask extends AsyncTask {
@Override
protected Bitmap doInBackground(Uri... uris) {
Uri imageUri = uris[0];
// 先去加载图片的大小
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
loadImage(imageUri, options);
// 根据需要对图片进行缩放,这里的取值是960
int maxSize = ImageCropView.MAX_CROP_SIZE;
int sampleSize = 1;
int width = options.outWidth;
int height = options.outHeight;
while (width > maxSize && height > maxSize) {
sampleSize *= 2;
width /= 2;
height /= 2;
}
Log.d(TAG, "sample size is " + sampleSize);
options.inJustDecodeBounds = false;
options.inSampleSize = sampleSize;
return loadImage(imageUri, options);
}
@Override
protected void onPostExecute(Bitmap bitmap) {
super.onPostExecute(bitmap);
if (bitmap == null) {
Toast.makeText(ImageCropActivity.this, "加载图片失败", Toast.LENGTH_LONG).show();
finish();
}
// 将Bitmap设置给我们的自定义控件ImageCropView
mImageCropView.setBitMap(bitmap);
}
}
private Bitmap loadImage(Uri uri, BitmapFactory.Options options) {
InputStream is = null;
try {
is = getContentResolver().openInputStream(uri);
if (is != null) {
return BitmapFactory.decodeStream(is, null, options);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
显示图片
拿到Bitmap资源之后,我们就可以将它显示出来了,但还不急。但显示图片之前,我们来分析下这个界面的组成先。
首先,图片处于最下层,然后上面是一层带有透明度的黑色蒙层,但最神奇的是这个蒙层在中间被挖出了个圆形,使得这部分区域不会被遮住。细心的你可能还会发现,图片的高度是跟裁剪区域的高度一致的。所以,这部分最关键的地方就是如何实现在蒙层中间去挖一个洞出来,处理好这部分之后,画个Bitmap还不是一个方法调用而已。
在蒙层中挖一个洞出来这个需求,只要知道了原理之后,其实也并不难,而这就需要我们利用混合模式来实现了。我们可以先画上蒙层,然后利用SRC_OUT
模式来挖洞。
// 为了方便看效果,将颜色设置为白色
mMaskColor = getResources().getColor(R.color.white);
mMaskXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT);
mMaskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mMaskPaint.setColor(Color.TRANSPARENT);
// 将Style设置为Fill模式,不能设置为Stroke
mMaskPaint.setStyle(Paint.Style.FILL);
@Override
protected void onDraw(Canvas canvas) {
int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null, Canvas.ALL_SAVE_FLAG);
// 画上蒙层
canvas.drawColor(mMaskColor);
mMaskPaint.setXfermode(mMaskXfermode);
// 利用SRC_OUT模式在中间挖一个圆出来
canvas.drawOval(mCropRectF, mMaskPaint);
mMaskPaint.setXfermode(null);
canvas.restoreToCount(sc);
// 画上白色圆环
canvas.drawOval(mCropRectF, mRingPaint);
}
几行代码过后,就得到了这样的效果。首先Activity的背景是黑色的,然后我们在画布画上了白色,然后利用SRC_OUT
模式让挖走中间这一块,就能够得到我们想要的效果了。
解决了蒙层的难点之后,我们再来显示图片,我们想要达到的效果是图片较小的边可以和裁剪区域一样大,这样就可以保证整张图片可以覆盖到整个裁剪区域了,所以,我们需要对图片进行缩放和位移的处理先。我们需要借助Matrix这个类来帮助我们操作图片。
/**
* 首次加载调整图片的显示
*/
private void fixImageSize() {
mMatrix.reset();
// 拿到裁剪区域的大小
float cropSize = mCropRectF.width();
int width = mSrcBitmap.getWidth();
int height = mSrcBitmap.getHeight();
int minSide = Math.min(width, height);
// 对较小的边进行缩放计算,
// 使得较小的一边宽度与裁剪框相等
if (minSide < cropSize) {
mInitScale = cropSize / minSide;
} else {
mInitScale = minSide / cropSize;
}
Log.d(TAG, "bitmap initialize scale is " + mInitScale);
float offsetX = 0;
float offsetY = 0;
// 计算图片的偏移值,使得图片相对于裁剪区域居中
if (minSide == width) {
offsetY = (height * mInitScale - cropSize) / 2;
} else {
offsetX = (width * mInitScale - cropSize) / 2;
}
// 对图片进行缩放
mMatrix.postScale(mInitScale, mInitScale);
// 居中显示
mMatrix.postTranslate(mCropRectF.left - offsetX, mCropRectF.top - offsetY);
invalidate();
}
在fixImageSize
这个方法里,我们先是拿到了裁剪区域的边长,然后拿图片较小的边来和它相比得到缩放倍数,接着再计算出另一边需要偏移多少,最后将这些操作设置给Matrix,然后触发重绘。
拿到Matrix后,就可以绘制图片了。
@Override
protected void onDraw(Canvas canvas) {
if (mSrcBitmap != null) {
canvas.drawBitmap(mSrcBitmap, mMatrix, null);
}
// 画完图片后绘制蒙层,代码不再重复给出了。
}
至此,最重要的一步完成了,看看实现的效果怎么样。
手势操作
图片加载出来之后,后面的就好办了,因为所有的操作都是基于这张图片来的。
拖动图片
拖动图片的操作,比较容易,思路是在ACTION_MOVE
中拿到当前的手指操作位置,然后通过Matrix去更新图片的位移。但这里需要注意的一点是,当你有多个手指在操作时,容易导致图片一下子移到别的地方去了,体验很不好,所以当检测到多指触碰的时候,屏蔽事件,并且如果不屏蔽的话还会和后面的双指缩放造成冲突。
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
// 多指触碰情况下屏蔽移动事件
if (event.getPointerCount() > 1) {
mLastPoint.x = -1;
mLastPoint.y = -1;
return true;
}
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastPoint.x = event.getX();
mLastPoint.y = event.getY();
case MotionEvent.ACTION_MOVE: {
if (mLastPoint.x != -1 && mLastPoint.y != -1) {
translateBitmap(x, y);
}
mLastPoint.x = x;
mLastPoint.y = y;
break;
}
case MotionEvent.ACTION_UP: {
if (!isInAnimation) {
Log.d(TAG, "ACTION_UP: not in animation.");
adjustImagePosition();
}
mLastPoint.x = 0;
mLastPoint.y = 0;
}
}
return true;
}
private void translateBitmap(float x, float y) {
mMatrix.postTranslate(x - mLastPoint.x, y - mLastPoint.y);
invalidate();
}
双击缩放
双击缩放需要GestureDetector
来辅助识别手势操作。只需要监听onDoubleTap
事件即可。
@Override
public boolean onDoubleTap(MotionEvent motionEvent) {
if (isInAnimation) {
return true;
}
// 图片处于放大状态,双击回到初始状态先
if (getScale() != mInitScale) {
mScaleAnimator.setFloatValues(getScale(), mInitScale);
mScaleAnimator.start();
} else {
mPivotX = motionEvent.getX();
mPivotY = motionEvent.getY();
mScaleAnimator.setFloatValues(mInitScale, mInitScale * 2);
mScaleAnimator.start();
}
return true;
}
这里的动画更新缩放倍数有必要说一下。我们在上面设置的放大参数是从当前倍数再放大两倍,在onAnimationUpdate
中拿到的数据是我们最后想应用在图片上的放大倍数,但由于Matrix本身就已经带有放大倍数了,所以我们还需要计算出实际上的放大倍数。
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float updateScale = (float) valueAnimator.getAnimatedValue();
// 拿到当前Matrix的放大倍数
float currentScale = getScale();
float actualScale = updateScale / currentScale;
mMatrix.postScale(actualScale, actualScale, mPivotX, mPivotY);
invalidate();
}
双指缩放
双指缩放需要ScaleGestureDetector
来辅助识别,需要监听onScale
事件。由于onScale
是一个不断回调的事件,所以不需要Animator的帮助了,直接操作后进行重绘即可。
@Override
public boolean onScale(ScaleGestureDetector scaleGestureDetector) {
float scaleFactor = scaleGestureDetector.getScaleFactor();
// 这里也需要计算出真实的缩放倍数
float scaleTo = getScale() * scaleFactor;
// 控制放大倍数为初始放大倍数的1~2倍之间
if (scaleTo < 2 * mInitScale && scaleTo > mInitScale) {
mMatrix.postScale(scaleFactor, scaleFactor, scaleGestureDetector.getFocusX(), scaleGestureDetector.getFocusY());
invalidate();
}
return true;
}
调整图片的显示位置
在进行手势操作后,图片可能会偏离裁剪区域,所以我们需要在用户操作完成后来调整图片的位置。具体触发时机是在ACTION_UP
和双击图片缩放动画完成后。
private void adjustImagePosition() {
float bmpLeft = getTranslationX();
float bmpRight = bmpLeft + mSrcBitmap.getWidth() * getScale();
float bmpTop = getTranslationY();
float bmpBottom = bmpTop + mSrcBitmap.getHeight() * getScale();
float circleLeft = mCropRectF.left;
float circleTop = mCropRectF.top;
float circleRight = mCropRectF.right;
float circleBottom = mCropRectF.bottom;
float translateX = 0;
float translateY = 0;
// 判断水平方向
if (bmpLeft > circleLeft) {
translateX = circleLeft - bmpLeft;
} else if (bmpRight < circleRight) {
translateX = circleRight - bmpRight;
}
// 判断垂直方向
if (bmpTop > circleTop) {
translateY = circleTop - bmpTop;
} else if (bmpBottom < circleBottom) {
translateY = circleBottom - bmpBottom;
}
PropertyValuesHolder xHolder = PropertyValuesHolder.ofFloat(TRANSLATION_X, 0, translateX);
PropertyValuesHolder yHolder = PropertyValuesHolder.ofFloat(TRANSLATION_Y, 0, translateY);
mTranslateAnimator.setValues(xHolder, yHolder);
mTranslateAnimator.start();
}
同样的,位移动画也和缩放动画一样,需要计算出真实的偏移值。
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float translateX = (float) valueAnimator.getAnimatedValue(TRANSLATION_X);
float translateY = (float) valueAnimator.getAnimatedValue(TRANSLATION_Y);
mMatrix.postTranslate(translateX - mLastTranslationX, translateY - mLastTranslationY);
mLastTranslationX = translateX;
mLastTranslationY = translateY;
invalidate();
}
预览裁剪图片
这一步是最简单的了,只需要将mMaskColor
设置为纯黑色的,然后重绘就完事了。加上这个操作只是锦上添花而已。
保存Bitmap
到这里为止,我们已经可以在这个界面自娱自乐了,然而如果不把图片裁剪后返回出去就是在耍流氓。所以是时候进入到最后一步去裁剪图片了。
在此之前,先来回忆一下我们是怎么对图片做操作的,无论是缩放还是平移,都是通过Matrix矩阵操作后在画图片的时候将矩阵应用到图片上的。也就是说,自始至终,原始图片mSrcBitmap
都是没有变的,而我们所操作的都是经过处理的图片。所以我们需要将我们所看到的给映射到原始图片mSrcBitmap
上。
public Bitmap saveImage(int size) {
// 计算出经过矩阵操作后图片的大小
RectF bitmapBound = new RectF(0, 0, mSrcBitmap.getWidth(), mSrcBitmap.getHeight());
mMatrix.mapRect(bitmapBound);
// 由于我们看到的是经过矩阵操作的图片,裁剪操作是要对原始图片进行的,
// 所以需要将矩阵处理过的图片的裁剪区域换算成原始图片的裁剪区域
float s = getScale();
int left = (int) ((mCropRectF.left - bitmapBound.left) / s);
int top = (int) ((mCropRectF.top - bitmapBound.top) / s);
int w = (int) (mCropRectF.width() / s);
int h = (int) (mCropRectF.height() / s);
Matrix matrix = new Matrix();
float scale = (float) size / w;
matrix.setScale(scale, scale);
// 需要注意的是,这个方法是先对原始进行裁剪操作后才去进行矩阵操作的
// 而不是对原始图片进行矩阵操作后再裁剪的
return Bitmap.createBitmap(mSrcBitmap, left, top, w, h, matrix, false);
}
经过上面的操作之后,总算把Bitmap给返回出去了。可是又好像还有点不对。慢着!说好的圆形头像呢,这样返回出去的是一张正方形的Bitmap啊!嘿嘿,这就要用到之前写的另一篇小文章自定义圆形头像了,其实这里再对Bitmap处理成圆形之后再给出去也是可以的。但假设拿到这个Bitmap的控件还从其他地方去获取图片,并且拿到的并不是圆形的,最终会造成效果不统一。所以这里就选择了返回一张没有处理过的Bitmap,至于圆形效果就让使用者自己去处理吧。
可改进的空间
- 既然可以裁剪圆形,那么也可以裁剪正方形的。只要加个标志位判断一下就行了。
- 有平移,缩放操作,还可以再加上旋转操作。
- ImageCropActivity最终会返回一个Bitmap给吊起它的Activity,如果Bitmap太大了会导致传不回去。