大体的功能如上gif所示,最后蓝色裁剪框中的矩形图片区域可以进行截取并返回一个Bitmap对象。整个裁剪功能由两个自定义的View组件完成,首先是图片显示控件DragScaleView,主要完成图片的双指触碰缩放和单指触碰滑动,以及根据裁剪框的变化对图片进行对应的平移和缩放;然后就是裁剪框控件DrawBoxView,他俩的父布局是FrameLayout,所以DrawBoxView是覆盖在DragScaleView组件上的,点击事件的处理优先级DrawBoxView>DragScaleView。
绘制裁剪框的代码:
paint.setColor(Color.BLUE);
paint.setStyle(Paint.Style.STROKE);//设置填充样式
paint.setStrokeWidth(4);
canvas.drawRect(mRect, paint);
float midHorizontal = (mRect.left + mRect.right) / 2;//水平方向中点
float midVertical = (mRect.top + mRect.bottom) / 2;//垂直方向中点
//绘制八个控制点
paint.setStrokeWidth(10);
float[] pts = {mRect.left - 5, mRect.top, mRect.left + 40, mRect.top,
midHorizontal - 20, mRect.top, midHorizontal + 20, mRect.top,
mRect.right - 40, mRect.top, mRect.right + 5, mRect.top,
mRect.left, mRect.top, mRect.left, mRect.top + 40,
mRect.right, mRect.top, mRect.right, mRect.top + 40,
mRect.left, midVertical - 20, mRect.left, midVertical + 20,
mRect.right, midVertical - 20, mRect.right, midVertical + 20,
mRect.left, mRect.bottom, mRect.left, mRect.bottom - 40,
mRect.right, mRect.bottom, mRect.right, mRect.bottom - 40,
mRect.left - 5, mRect.bottom, mRect.left + 40, mRect.bottom,
midHorizontal - 20, mRect.bottom, midHorizontal + 20, mRect.bottom,
mRect.right - 40, mRect.bottom, mRect.right + 5, mRect.bottom};
canvas.drawLines(pts, paint);
mRect代表的就是整个裁剪框所表示的矩形区域,八个控制点的绘制就是在mRect对应的位置上用更粗的画笔绘制指定长度即可。这个mRect的初值应等于图片的显示区域大小,例如DragScaleView宽高指定为300dp和500dp,DrawBoxView宽高设置为310dp和510dp,那么DrawBoxView在上下左右四个方向上都会多出5dp的空间来确保八个控制点加粗区域不会被遮挡,所以mRect的初始值即为new RectF(dp2px(5), dp2px(5), dp2px(300)+ dp2px(5), dp2px(500)+ dp2px(5)),dp2px()方法是把dp单位的值换算为像素单位,如图:
图中红色框体就是图片的显示区域即DragScaleView组件的大小,也就是裁剪框的位置大小,可以很清楚的看到DrawBoxView之所以要大一些的原因就是为了裁剪框的八个控制区域可以完整的显示出来。下面就是裁剪框组件和图片组件的点击事件分发,如果触点是在八个控制点上即需要改变裁剪框的大小,那么DrawBoxView就会消耗掉点击事件,如果触点不在八个控制点上即需要对图片直接进行操作,那么DrawBoxView就不消耗该事件,然后点击事件就会按照View树中的顺序传递给DragScaleView,接着就是双指缩放和单指滑动的处理。先来看DrawBoxView中对于触点位置的判断:
@Override
public boolean onTouchEvent(MotionEvent event) {
mActionMovePoint.x = (int) event.getX();
mActionMovePoint.y = (int) event.getY();
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
… …
if (getPosition(mActionMovePoint.x, mActionMovePoint.y) != -1) {
return true;//触点在某个控制点上就消耗掉事件
} else {
return false;//触点在其他位置就不消耗
}
case MotionEvent.ACTION_MOVE:
… …
先获取手指按下时触点位置,再传递给getPosition方法进行判断,八个点的标号为0-7,其他位置的标号为-1:
private int getPosition(float x, float y) {
if (mPressPointIndex > -1 && mPressPointIndex < 8) {
return mPressPointIndex;
}
int POINT_RADIUS = 2500;//触点可响应区域的半径平方
if ((x - mRect.left) * (x - mRect.left) + (y - mRect.top) * (y - mRect.top) < POINT_RADIUS) {
return 0;
} else if ((x - (mRect.left + mRect.right) / 2) * (x - (mRect.left + mRect.right) / 2) +
(y - mRect.top) * (y - mRect.top) < POINT_RADIUS) {
return 1;
} else if ((x - mRect.right) * (x - mRect.right) + (y - mRect.top) * (y - mRect.top) < POINT_RADIUS) {
return 2;
} else if ((x - mRect.left) * (x - mRect.left) + (y - (mRect.top + mRect.bottom) / 2) *
(y - (mRect.top + mRect.bottom) / 2) < POINT_RADIUS) {
return 3;
} else if ((x - mRect.right) * (x - mRect.right) + (y - (mRect.top + mRect.bottom) / 2) *
(y - (mRect.top + mRect.bottom) / 2) < POINT_RADIUS) {
return 4;
} else if ((x - mRect.left) * (x - mRect.left) + (y - mRect.bottom) * (y - mRect.bottom) < POINT_RADIUS) {
return 5;
} else if ((x - (mRect.left + mRect.right) / 2) * (x - (mRect.left + mRect.right) / 2) +
(y - mRect.bottom) * (y - mRect.bottom) < POINT_RADIUS) {
return 6;
} else if ((x - mRect.right) * (x - mRect.right) + (y - mRect.bottom) * (y - mRect.bottom) < POINT_RADIUS) {
return 7;
}
return -1;
}
如果触点是八个点中的一个,就要在ACTION_MOVE中去改变mRect的大小并实时重绘来实现裁剪框在跟随手指移动的效果:
case MotionEvent.ACTION_MOVE:
switch (getPosition(mActionMovePoint.x, mActionMovePoint.y)) {
case 0:
mRect.left = mActionMovePoint.x;
mRect.top = mActionMovePoint.y;
mPressPointIndex = 0;
break;
case 1:
mRect.top = mActionMovePoint.y;
mPressPointIndex = 1;
break;
… …
}
invalidate();
case MotionEvent.ACTION_UP:
… …
mPressPointIndex = -1;
break;
… …
在按下控制点调整裁剪框的过程中,ACTION_MOVE中的逻辑一直在被重复执行,即从按下左上角控制点后开始滑动以调整裁剪框大小到手指抬起的整个过程中mPressPointIndex的值都是0,手指抬起时再置为-1,所以在getPosition方法的最开始有这个if判断:
if (mPressPointIndex > -1 && mPressPointIndex < 8) {
return mPressPointIndex;
}
只有当mPressPointIndex的值为-1时才会走后面的逻辑来重新判断触点的位置,如果mPressPointIndex的值为0-7则说明此时手指还在滑动尚未抬起,mPressPointIndex的值就一定是当前值不用再走后面的一大串if判断直到手指抬起mPressPointIndex重新置为-1,然后手指再次按下,那么就重新进行判断。
手指抬起后需要做的是将裁剪框进行放大,直到宽或高达到最大值,这个最大值就是mRect的初始宽高
case MotionEvent.ACTION_UP:
if (mPressPointIndex != -1) {
float xScale = mInitRect.width() / mRect.width();
float yScale = mInitRect.height() / mRect.height();
float scale = xScale > yScale ? yScale : xScale;
if (mCropListener!=null) {
mCropListener.onUp(mPressPointIndex, scale, xOffset, yOffset, mRect);
}
doAnimation(scale);
}
mPressPointIndex = -1;
break;
这个CropListener接口的onUp方法就是在手指抬起时把裁剪框的缩放比例scale传给DragScaleView,然后图片控件会根据这个scale对图片进行等比缩放,裁剪框的缩放动画都在doAnimation方法中,其实就是动态设置mRect的left、top、right、bottom四个属性值,要注意的是这四个值还记得要加上dp2px(5):
private void doAnimation(float scale) {
float left = (mInitRect.width() - mRect.width() * scale) / 2 + dp2px(5);//动画结束后left的值
float right = left + mRect.width() * scale;
float top = (mInitRect.height() - mRect.height() * scale) / 2 + dp2px(5);
float bottom = top + mRect.height() * scale;
ValueAnimator rectAnimator = ValueAnimator.ofObject(new RectEvaluator(), mRect, new RectF(left, top, right, bottom));
rectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mRect = (RectF) animation.getAnimatedValue();
invalidate();
}
});
rectAnimator.setDuration(500);
rectAnimator.start();
}
class RectEvaluator implements TypeEvaluator {
@Override
public RectF evaluate(float fraction, RectF startValue, RectF endValue) {
RectF rectf = new RectF();
rectf.left = startValue.left + (endValue.left - startValue.left) * fraction;
rectf.right = startValue.right + (endValue.right - startValue.right) * fraction;
rectf.top = startValue.top + (endValue.top - startValue.top) * fraction;
rectf.bottom = startValue.bottom + (endValue.bottom - startValue.bottom) * fraction;
return rectf;
}
}
这里的属性动画如果四个属性一个一个去设置我觉得太麻烦,就自定义了一个估值器RectEvaluator,这样四个属性一起弄就挺方便的,关于估值器有不懂的可以去看看启舰大神的文章。DrawBoxView里面的主要逻辑就这些,然后裁剪框其实是有一个最大和最小范围,这个范围的设置是跟图片的缩放平移有密切关系,这个比较麻烦,后面会详细说。
在DragScaleView中双指缩放和单指滑动是分别托管给了ScaleGestureDetector mScaleDetector和GestureDetector mGestureDetector两个监听器,这两个监听的响应回调如下:
/缩放
private class SimpleScaleListenerImpl extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
mScaleFactor *= detector.getScaleFactor();
mScaleFactor = Math.max(1f, Math.min(mScaleFactor, 4f));//缩放倍数范围:1~4
invalidate();
return true;
}
}
//移动
private class SimpleGestureListenerImpl extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
mPosX -= distanceX;
mPosY -= distanceY;
invalidate();
return true;
}
}
mScaleFactor是当前图片的缩放倍数,范围是100%-400%,mPosX和mPosY是平移之后画布的坐标,这俩监听器的初始化逻辑如下:
mScaleDetector = new ScaleGestureDetector(context, new SimpleScaleListenerImpl());
mGestureDetector = new GestureDetector(context, new SimpleGestureListenerImpl());
onTouchEvent方法中的逻辑也很简单,直接把event托管过去就可以了:
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
//双指缩放
mScaleDetector.onTouchEvent(event);
//单指移动
mGestureDetector.onTouchEvent(event);
return true;
}
对图片进行缩放后,图片的可平移范围也要动态设置,图片的显示范围以裁剪框为界,如图所示:
这个范围的计算牵扯到一堆变量,我自己也不是很精通算法,在每次重绘调用onDraw方法时都会重新判定图片可以平移的范围,就是这个checkBounds方法:
private void checkBounds() {
if (mScaleFactor >= getWidth() / mNewWidth) {
//宽度方向已经填满
mPosX = Math.min(mPosX, (mScaleFactor - 1) * (mNewWidth / 2) + mMaxRight);//最右 mPosX<=
mPosX = Math.max(mPosX, getWidth() - mNewWidth - (mScaleFactor - 1) * (mNewWidth / 2) + mMaxLeft);//最左 mPosX>=
}
if (mScaleFactor >= getHeight() / mNewHeight) {
//高度方向已经填满
mPosY = Math.min(mPosY, (mScaleFactor - 1) * (mNewHeight / 2) + mMaxBottom);//最下 mPosY<=
mPosY = Math.max(mPosY, getHeight() - mNewHeight - (mScaleFactor - 1) * (mNewHeight / 2) + mMaxTop);//最上 mPosY>=
}
}
如上图中所示,以向右平移为例,图中蓝色和绿色的线段长度分别代表(mScaleFactor - 1) * (mNewWidth / 2)和mMaxRight的值,mNewWidth是显示区域的宽度(图片初始宽度),至于mMaxRight的计算很麻烦,并不是简单的用显示区域的宽度减去当前裁剪框宽度除以2就可以了,这个值会影响整个缩放动画的执行,必须以动效的形式指定这个值不然会有问题:
float dx = x / 2 - rectF.width() * (scale - 1) / 2;
float dy = y / 2 - rectF.height() * (scale - 1) / 2;
这个rectF是手指抬起那一瞬间的裁剪框对应的矩形区域,x、y是手指按下到手指抬起两点间的x、y方向的偏移,计算出来的dx和dy就是单次滑动裁剪框之后mMaxRight、mMaxLeft、mMaxBottom、mMaxTop的增量或者减量,然后得添加为属性动画才可以保证在执行动画期间图片不会超出四个方向上的边界,否则可能出现图片闪烁的问题,这里面的原因牵扯太多不再赘述了:
ValueAnimator maxPosAnimator = ValueAnimator.ofObject(new MaxPosEvaluator(), new MaxPosition(mMaxLeft, mMaxRight, mMaxTop,
mMaxBottom), new MaxPosition(mMaxLeft - dx, mMaxRight + dx, mMaxTop - dy, mMaxBottom + dy));
同样的,图片平移有范围限制,那么裁剪框的滑动也是被限制的,裁剪框以图片边界为界,最大只能滑动到显示区域的边界,如下图所示:
这个范围的确定必须根据mPosX和mPosY两个值的变化量来确定,DragScaleView的onDraw方法中会去调UpdateListener的onChange方法,把实时的mPosX、mPosY、mScaleFactor三个值传给DrawBoxView,然后在DrawBoxView的ACTION_MOVE中实时判断可滑动区域,这个算法逻辑也挺复杂,写到最后我自己也有点懵,代码如下:
float xMaxLeftOffset = 0f;//裁剪框往左方向(包括左上、左、左下)可滑动到的最大偏移量
float yMaxTopOffset = 0f;//裁剪框往上方向(包括左上、上、右上)可滑动到的最大偏移量
float xMaxRightOffset = 0f;//裁剪框往右方向(包括右上、右、右下)可滑动到的最大偏移量
float yMaxBottomOffset = 0f;//裁剪框往下方向(包括左下、下、右下)可滑动到的最大偏移量
@Override
public boolean onTouchEvent(MotionEvent event) {
mActionMovePoint.x = (int) event.getX();
mActionMovePoint.y = (int) event.getY();
switch (event.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
Toast.makeText(getContext(), "" + getPosition(event.getX(), event.getY()), Toast.LENGTH_SHORT).show();
actionDownRectLeft = mRect.left;
actionDownRectTop = mRect.top;
actionDownRectRight = mRect.right;
actionDownRectBottom = mRect.bottom;
xMaxLeftOffset = mRect.width() == mInitRect.width() ? 0 : (mPosX - (mScaleFactor - 1) * (mInitRect.width() / 2));
yMaxTopOffset = mRect.height() == mInitRect.height() ? 0 : (mPosY - (mScaleFactor - 1) * (mInitRect.height() / 2));
xMaxRightOffset = mRect.width() == mInitRect.width() ? 0 : (mPosX + (mScaleFactor - 1) * (mInitRect.width() / 2));
yMaxBottomOffset = mRect.height() == mInitRect.height() ? 0 : (mPosY + (mScaleFactor - 1) * (mInitRect.height() / 2));
if (getPosition(mActionMovePoint.x, mActionMovePoint.y) != -1) {
return true;
} else {
return false;
}
case MotionEvent.ACTION_MOVE:
switch (getPosition(mActionMovePoint.x, mActionMovePoint.y)) {
case 0:
mRect.left = mActionMovePoint.x;
mRect.top = mActionMovePoint.y;
xOffset = mActionMovePoint.x - actionDownRectLeft;
yOffset = mActionMovePoint.y - actionDownRectTop;
//设置裁剪框可缩放到的最小区域大小以及裁剪之后x、y方向的偏移值
if ((actionDownRectRight - mActionMovePoint.x) < RECT_MIN_WIDTH) {
mRect.left = actionDownRectRight - RECT_MIN_WIDTH;
xOffset = mRect.left - actionDownRectLeft;
}
if ((actionDownRectBottom - mActionMovePoint.y) < RECT_MIN_HEIGHT) {
mRect.top = actionDownRectBottom - RECT_MIN_HEIGHT;
yOffset = mRect.top - actionDownRectTop;
}
//设置裁剪框可缩放到的最大区域大小以及裁剪之后x、y方向的偏移值
float xMaxLeft = mInitRect.left + xMaxLeftOffset < 0 ? 0 : mInitRect.left + xMaxLeftOffset;
float yMaxTop = mInitRect.top + yMaxTopOffset < 0 ? 0 : mInitRect.top + yMaxTopOffset;
if (mActionMovePoint.x < xMaxLeft) {
mRect.left = xMaxLeft;
xOffset = mRect.left - actionDownRectLeft;
}
if (mActionMovePoint.y < yMaxTop) {
mRect.top = yMaxTop;
yOffset = mRect.top - actionDownRectTop;
}
mPressPointIndex = 0;
break;
… …
最后的一个主要功能就是需要根据裁剪框的位置和图片的大小位置截取出相应的图片区域,下面的方法会返回一个Bitmap对象,这里的逻辑就是要获取到当前裁剪框包裹住的图片区域的左上角坐标,截取出的图片宽高就是当前裁剪框区域的宽高mRect.width()和mRect.height(),其实要计算这个左上角的坐标值,以横坐标为例:需要算出当前图片和原图的宽度差的一半再减去图片的x方向偏移量mPosX(往右移mPosX为正,往左移mPosX为负),然后加上裁剪框的左边距,最后还要记得减去多算进来的那截5dp:
public Bitmap getImage () {
Matrix matrix = new Matrix();
matrix.postScale(mScaleFactor, mScaleFactor);
Bitmap bitmap = null;
int ox = 0, oy = 0;
if (mNewBitmap != null && !mNewBitmap.isRecycled()) {
bitmap = Bitmap.createBitmap(mNewBitmap, 0, 0, (int) mNewWidth, (int) mNewHeight, matrix, true);
ox = (int) ((bitmap.getWidth() - getWidth()) * 0.5f - mPosX + mRect.left + 0.5f) – dp2px(5);
oy = (int) ((bitmap.getHeight() - getHeight()) * 0.5f - mPosY + mRect.top + 0.5f) - dp2px(5);
}
if (bitmap != null && !bitmap.isRecycled() && ox >= 0 && oy >= 0) {
Bitmap cutBitmap = Bitmap.createBitmap(bitmap, ox, oy, (int) mRect.width(), (int) mRect.height());
return cutBitmap;
} else {
return null;
}
}
首先是在xml中的布局,他俩的父布局是FrameLayout
在MainActivity中除了初始化两个矩形区域外还要设置二者相互传递数据的接口:
dragScaleView.setRect(new RectF(0, 0, dp2px(300), dp2px(500)));
dragScaleView.setImageResource(R.mipmap.test);
dragScaleView.setUpdateListener(new DragScaleView.UpdateListener() {
@Override
public void onChange(float posX, float posY, float scale) {
drawBoxView.setValues(posX, posY, scale);
}
});
drawBoxView.setRect(new RectF(dp2px(5), dp2px(5), dp2px(300) + dp2px(5), dp2px(500) + dp2px(5)));
drawBoxView.setCropListener(new DrawBoxView.CropListener() {
@Override
public void onUp(int positionIndex, float scale, float xOffset, float yOffset, RectF rectF) {
dragScaleView.doAnimation(positionIndex, scale, xOffset, yOffset, rectF);
}
});
这里面图片跟随裁剪框大小改变而移动位置还有些问题,希望有大佬可以一起探讨下,代码下载链接:
https://download.csdn.net/download/qq_37159335/12004255