Android裁剪功能实现
概述
从4月初到5月份 ,差不多一个多月,终于把裁剪图片的功能码出来了,期间,解决了一个又来一个问题,好吧,问题总是会有的。
这里大致介绍这个裁剪功能技术点、主要难点,实现原理。
技术点
- 图片缩放、移动
- 裁剪区域预览
- 裁剪(包括越图片边界裁剪)
- 边界限制
主要难点
- 裁剪区域预览
- 裁剪
- 边界限制
实现原理
裁剪预览区域的实现
在我做过的项目中,就有使用过一些网络上开源的裁剪功能:半透明遮罩层的矩形预览框功能。它的实现原理是在裁剪预览区域外的地方填充了几个半透明的矩形框,进而实现了矩形裁剪预览框功能,如下图。
这种功能虽然可以实现预览功能,但是仅仅局限于当预览区外的地方可以通过规则的形状填充,如果是圆形的裁剪预览框,那么就没办法通过这种方式来实现了。
所以我们需要另外想过办法来实现圆形的预览框。在一开始的时候,我这边的思路是通过在半透明的遮罩层上镂空一个预览框。我们来试试在半透明的遮罩层上叠加一个透明的预览框。
public void onDraw(Canvas canvas){
//绘画半透明遮罩
canvas.drawColor(Color.parseColor("#90000000"));
Paint paint = new Paint();
paint.setColor(Color.TRANSPARENT);
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
int left = getWidth()/2;
int top = getHeight()/2;
//绘制透明预览框
canvas.drawCircle(left, top, 300, paint);
}
效果可见下图。
可以看出,虽然在中间白色的预览框是全透明的一个裁剪预览框,,下面还是会有一层半透明的遮罩层覆盖住图片,实现不了预览框全透明的效果。
看来这种简单的叠加方式是无法实现我们的需求,所以通过搜寻资料,最终,发现可以采用Xfermode方式来实现。通过设定Xfermode模式,可以将两个重叠的层通过一定的方式来显示,例如下层是半透明遮罩,上层是透明圆形框,那么可以通过设置相应的Xfermode模式来实现。我们改一下上面的代码:
public void onDraw(Canvas canvas){
//这里需要通过bitmap创建canvas才能对Xfermode生效果,具体原因这里也不大清楚
Bitmap bitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_4444);
Canvas xFerCanvas = new Canvas(bitmap);
//绘画半透明遮罩
xFerCanvas.drawColor(Color.parseColor("#90000000"));
Paint paint = new Paint();
paint.setColor(Color.TRANSPARENT);
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
//设置当前画笔的Xfermode模式,不同的模式效果可以参照Google提供的Demo-ApiDemos/Graphics/XferModes
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
int left = getWidth()/2;
int top = getHeight()/2;
//绘制透明预览框
xFerCanvas.drawCircle(left, top, 300, paint);
//最后将生成的bitmap绘制到我们的画布上
canvas.drawBitmap(bitmap,0,0,null);
bitmap.recycle();
System.gc();
}
效果可见下图
可以看出,实现了我们想要的效果。
对Xfermode更加详细的讲解可以阅读博文时之沙-Android 颜色渲染(九) PorterDuff及Xfermode详解,里面有详细的讲解不同的Xfermode对层叠加的不同效果。虽然这种方案可以实现效果,但是这种方案有一个很大的缺点,就是需要创建一个新的Bitmap,会导致内容占用率大量提高。所以这里通过了博文JianTao_Yang-Android ImageCropper 矩形 圆形 裁剪框找到了第二种方案。第二种方案的实现思路是:在绘画半透明遮罩之前,先将画布可以绘画位置限定在裁剪预览框之外,这样绘画的半透明遮罩自然就空下了中间的预览框,这样就实现了该功能。
public void onDraw(Canvas canvas){
Paint paint = new Paint();
paint.setColor(Color.TRANSPARENT);
paint.setStyle(Paint.Style.FILL);
paint.setAntiAlias(true);
int left = getWidth()/2;
int top = getHeight()/2;
//创建圆形预览框
Path path = new Path();
path.addCircle(left, top, 300, Path.Direction.CW);
//保存当前canvas 状态
canvas.save();
//将当前画布可以绘画区域限制死为预览框外的区域
canvas.clipPath(path, Region.Op.DIFFERENCE);
//绘画半透明遮罩
canvas.drawColor(Color.parseColor("#90000000"));
//还原画布状态
canvas.restore();
}
这里就不贴图了,最终效果与采用Xfermode叠加方案是一样,并且不需要创建新的Bitmap,不会导致内存占用率大量提高。但是这种方案也有其局限性,由于我们只能通过Path来限制其在画布可绘画的区域,并且Path只支持一些几何形的图案,所以预览框形状被限死在几何形图案集合内。
这里总结一下上面两种方案和其应用场景:
- 如果是几何形的预览框,那么首推限制绘画区域的方案,内存占用率低。
- 如果是非几何形的预览框(例如卡通形状的预览框),那么在这里给出的方案里,你只能通过Xfermode方式来实现了,不过使用这种方式需要注意内存的占有率。
图片缩放&移动实现
这里的图片缩放、移动全部通过Matrix实现的。其实移动的实现方式可以采用两种方式:
- View.scrollBy(int,int)或者View.scrollTo(int,int)方式实现.
- 图片Matrix处理。
但是这里由于需要实现缩放功能,所以干脆统一采用Matrix方式来实现。Matrix是一组参数集合,其中不同的参数对应着不同的功能处理(平移/缩放等),具体可以查看博文Qiengo-Android Matrix
这里只讲述Matrix实现的缩放与移动,不对View.scroll**移动方式多做说明。
在通过Matrix实现缩放、移动之前,需要调用ImageView.setScaleType(ImageView.ScaleType.MATRIX),将ImageView的缩放方式设置为MATRIX。
Matrix移动实现
Matrix移动的实现十分简单,通过记录最后移动点与当前移动点的距离就可以实现移动功能。
//motionX,motionY为当前触摸的坐标
public void drag(float motionX, float motionY) {
//mLastY,mLastX 为上一次触摸的坐标
float moveX = motionX -mLastX;
float moveY = motionY - mLastY;
//通过postTranslate方法就可以移动到相应的位置
mView.getImageMatrix().postTranslate(moveX,moveY);
//重画视图
mView.invalidate();
}
Matrix缩放实现
Martix缩放功能也是相对简单。通过ScaleGestureDetector方式实现了缩放功能。我们主要通过实现ScaleGestureDetector,重写onScale方法,当然由于与移动功能叠加,所以需要在缩放的时候,屏蔽掉移动功能,所以我们需要记录缩放开始与结束。
@Override
public boolean onScale(ScaleGestureDetector detector) {
//px,py为缩放的中心点,以该点为中心点进行缩放
float px = detector.getFocusX();
float py = detector.getFocusY();
//缩放的比例 大于1为放大, 小于1为缩小
float scaleFactor= detector.getScaleFactor();
//通过postScale方式来实现缩放效果
mView.getImageMatrix().postScale(scaleFactor,scaleFactor,
px,py);
//重画视图
mView.invalidate();
return true;
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
//设置缩放标志位
isScale = true;
return true;
}
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
isScale = false;
}
@Override
public boolean touch(MotionEvent event){
//缩放手势处理
mGestureDetector.onTouchEvent(event);
//如果不在缩放中,则处理普通的触摸事件
if(!isScale){
}
}
return false;
}
裁剪功能实现
如果没有移动与缩放功能,那么裁剪会是一个相当简单的功能,因为其裁剪的位置总是固定的,但是如果加入了移动与缩放,那么事情就变的复杂了。当移动后与缩放后,裁剪的位置与大小都发生了变化,另外,移动和缩放可能导致图片部分或者全部不在预览框内,这些情况我们都需要进行处理,下面我们看看怎么正确的裁剪出预览框显示的图片。由于为了照顾到图片不在预览框的情况,所以我们采用了以下方式来做最终的图片裁剪:
public void crop() {
//其中width与height是最终实际裁剪的图片大小,saveBitmap就是最终裁剪的图片
Bitmap saveBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_4444);
Canvas canvas = new Canvas(saveBitmap);
//bitmap为原图,这里就是最终裁剪图片的实现方式,其中cropRect是裁剪区域,showRect是最终显示在画布的区域
canvas.drawBitmap(bitmap, cropRect, showRect, new Paint());
}
从上面代码段,我们可以清晰的知道影响裁剪的因素有:
- 最终裁剪的图片大小
- 实际裁剪的四个角的位置(相对于原图)
- 显示裁剪图片的四个角的位置(相对于画布)
注意以下的计算的前置条件是原图片中心点与预览框中心点均与屏幕中心点重叠
其中裁剪的图片大小我们很容易就可以根据裁剪预览框的大小与原图片缩放的倍数来获取。
我们通过裁剪的左上角起始坐标与最终裁剪的图片大小,来获取裁剪的四个位置。
//actuallyWidth与actuallyHeight为裁剪的实际长宽
//原图中心点x坐标--实际图片x坐标中心点-横坐标的实际偏移量,就可以得出裁剪的左上角位置
//,由于这里采用比较心点的方式去得到实际横坐标偏移量,所以这里可以不用理会缩放与移动产生的偏移量。
cropLeft = (int) (bitmap.getWidth()/2-mImageView.getActuallyScrollX()/scale-actuallyWidth/2);
//这里也是一样
cropTop = (int) (bitmap.getHeight()/2-mImageView.getActuallyScrollY()/scale - actuallyHeight/2);
//这里会进行边界判断,默认右边点为左边点+宽度
int cropRight = cropLeft+actuallyWidth;
int cropBottom = cropTop+actuallyHeight;
//裁剪总宽度超出原图宽度,需要重新设置右边点位置为图片宽度
if(cropRight>bitmap.getWidth()){
cropRight = bitmap.getWidth();
}
//裁剪总高度超出原图高度,需要重新设置右边点位置为图片高度
if(cropBottom>bitmap.getHeight()){
cropBottom = bitmap.getHeight();
}
而显示区域的四个角位置,获取就相对简单。其中左与上固定为0,剩下的就是右边与底部点了。
//由于裁剪区域与显示区域长宽应该是一致的,所以这里默认右边与底部为最终裁剪大小
int showRight = actuallyWidth;
int showBottom = actuallyHeight;
int cropRight = cropLeft+actuallyWidth;
int cropBottom = cropTop+actuallyHeight;
//裁剪超出图片边界超出边界
if(cropRight>bitmap.getWidth()){
cropRight = bitmap.getWidth();
//由于左固定为0,那么这里相应也要调整右边位置,让宽度与裁剪区域一致
showRight = bitmap.getWidth()-cropLeft;
}
if(cropBottom>bitmap.getHeight()){
cropBottom = bitmap.getHeight();
//由于上位置固定为0,那么这里相应也要调整底部位置,让高度与裁剪区域一致
showBottom = bitmap.getHeight()-cropTop;
}
至此,裁剪所需要的参数全部计算完毕,这样就可以正确裁剪出预览框中的内容。
边界限制
为了提升用户体验,或者是实现需求,可能我们需要限制缩放&移动的边界,让裁剪预览框的区域可以完全在图片里面,换个意思就是说,裁剪最后的图片一定是图片上的某个区域,而不会出现只裁剪到一部分图片,另一部分是空白的。
边界的限制只是针对移动与缩放。下面我们分别看看怎么对两者做边界限制
移动边界限制
同样由于涉及到了缩放,移动的边界限制需要特别处理。具体的思路是获取当前移动的距离与当前图片在屏幕上实际的四个位置点(左右上下),例如我们需要判断是否会超过左边界,那么我们会判断横坐标移动的距离+图片当前左边位置是否大于限制框的左边横坐标,是的话,那么则视为出界,应当重新计算移动距离。其他三个位置亦是如此,我们还是看下下面的代码片。
public void drag(float motionX, float motionY) {
//移动距离
float moveX = motionX -mLastX;
float moveY = motionY - mLastY;
//mRestrictRect为限制框,这个框实质就是预览框在屏幕上的坐标位置
if(mRestrictRect!=null){
//经过缩放与移动后,图片在屏幕上实际的位置
RectF rectF = getCurrentRectF();
//下面为四边边界的判断与重计算
if(moveX>0){
if(rectF.left+moveX>mRestrictRect.left){
moveX = mRestrictRect.left-rectF.left;
}
}else {
if(rectF.right+moveX<mRestrictRect.right){
moveX = mRestrictRect.right- rectF.right;
}
}
if(moveY>0){
if(rectF.top+moveY>mRestrictRect.top){
moveY = mRestrictRect.top-rectF.top;
}
}else {
if(rectF.bottom+moveY<mRestrictRect.bottom){
moveY = mRestrictRect.bottom- rectF.bottom;
}
}
}
mView.getImageMatrix().postTranslate(moveX,moveY);
mView.invalidate();
}
缩放边界限制
缩放边界的限制会相对复杂。因为当缩放出界时,需要根据多种情况重新计算缩放所需要的参数。
缩放边界限制的流程是:
1、按照缩放值,获取图片将会在屏幕出现的位置
2、判断四个位置是否会超出边界,并记录四个位置边界判断结果。如果缩放后的某个位置会超出限制框的边界位置,那么则限定该坐标为缩放中心点,保证该点位置不移动。例如左边将会出界,那么则以限制框的左边位置作为最终缩放中心点横坐标。
3、如果左右或者上下出界,则不用进行缩放,因为无法缩放了。如果不是这种情况,则进入4
4、根据Martrix缩放的计算公式推导出,什么缩放倍数下,会达到限制框的边界值。这里将会取到四个边界缩放值与当前的缩放值进行比对,取其中最大的缩放值作为最后的缩放值(因为缩小情况才会导致越界)
我们看看具体的代码片
public boolean onScale(ScaleGestureDetector detector) {
//初始化缩放值
float px = detector.getFocusX();
float py = detector.getFocusY();
float scaleFactor= detector.getScaleFactor();
if(mRestrictRect!=null){
Matrix matrixAfter = new Matrix(mView.getImageMatrix());
matrixAfter.postScale(detector.getScaleFactor()
,detector.getScaleFactor(),detector.ge tFocusX(),detector.getFocusY());
final BitmapDrawable drawable = (BitmapDrawable) mView.getDrawable();
final Bitmap bitmap = drawable.getBitmap();
RectF rectF = new RectF(0,0,bitmap.getWidth(),bitmap.getHeight());
//上面的大段代码都是为了这里,这里将会获取按照当前缩放值缩放后的图片实际的坐标位置
matrixAfter.mapRect(rectF);
boolean isLeftLimit = false ,isRightLimit = false ,
isTopLimit = false ,isBottomLimit =false;
//判断缩放后的位置是否会超过边界
if(rectF.left>mRestrictRect.left){
//超过边界则将点最为最后的缩放中心点,让该边界的点固定下来,不被改变
px = mRestrictRect.left;
isLeftLimit = true;
}
if(rectF.right<mRestrictRect.right){
px = mRestrictRect.right;
isRightLimit = true;
}
if(rectF.top>mRestrictRect.top){
py = mRestrictRect.top;
isTopLimit = true;
}
if(rectF.bottom<mRestrictRect.bottom){
py = mRestrictRect.bottom;
isBottomLimit = true;
}
//左右两边或者上下两边都无法缩放,就不缩放了
if((isRightLimit&&isLeftLimit)||(isTopLimit&&isBottomLimit)){
return true;
}
//重新计算允许的最小缩放倍数,根据四条边界的缩放倍数与当前的缩放倍数,
//获取最大缩放倍数,因为主要是缩小才会导致超越边界
//计算公式是: 结果坐标(ResultX) = 缩放前坐标(Before X)*缩放倍数(scale)
//+中心点坐标(center X)*(1-缩放倍数(scale))
float maxScaleLeft = (mRestrictRect.left-px)/(getCurrentRectF().left-px);
if(scaleFactor<maxScaleLeft){
scaleFactor = maxScaleLeft;
}
float maxScaleRight = (mRestrictRect.right-px)/(getCurrentRectF().right-px);
if(scaleFactor<maxScaleRight){
scaleFactor = maxScaleRight;
}
float maxScaleTop = (mRestrictRect.top-py)/(getCurrentRectF().top-py);
if(scaleFactor<maxScaleTop){
scaleFactor = maxScaleTop;
}
float maxSacleBottom = (mRestrictRect.bottom-py)/(getCurrentRectF().bottom-py);
if(scaleFactor<maxSacleBottom){
scaleFactor = maxSacleBottom;
}
}
//保存当前的缩放值
mScale=mScale*scaleFactor;
//执行缩放
mView.getImageMatrix().postScale(scaleFactor,scaleFactor,
px,py);
mView.invalidate();
return true;
}
GitHub地址
最后附录上EnjoyCrop在GitHub上的地址–EnjoyCrop,希望这篇文档对你有帮助,谢谢!