Android涂鸦及图片缩放拖动

产品需求:

图片可用画笔涂鸦,双指可缩放及拖动图片。注:为了减少网络传输,客户端向服务端传递画笔路径而非图片,服务端用收到的路径还原图片。

技术分析:

  • 图片适配屏幕
  • 图片缩放和拖动
  • 涂鸦功能的实现
  • 记录画笔路径
  • 不同的图片加载到内存中是否会过大,造成OOM
  • 图片缩放和拖动的算法是否可以更优
  • 画笔绘制是否圆滑,且功能在低端机上是否流畅可行

代码解析:

一、变量声名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*预览缩放比(初始化图片是,图片的压缩比*/
private float mInitScale;
/*用于对图片进行移动和缩放变换的矩阵*/
private Matrix mMatrix = new Matrix();
/*记录图片横向偏移值*/
private float mTotalTranslateX;
/*记录图片纵向偏移值*/
private float mTotalTranslateY;
/*记录图片总缩放比例*/
private float mTotalScale;
/*画笔*/
private Paint mPaint;

/*记录当前操作的状态*/
private int mCurrentStatus = 0;
/*初始化状态常量*/
public static final int STATUS_INIT = 1;
/*图片变化:移动和缩放*/
public static final int STATUS_CHANGE = 2;
/*涂鸦状态常量*/
public static final int STATUS_HANDWRITING = 3;
/*清除涂鸦*/
public static final int STATUS_CLEAR = 4;
/*撤销涂鸦*/
public static final int STATUS_UNDO = 5;

/*缩放控制器*/
private ScaleGestureDetector mScaleDetector;
/*图片被拖动的X距离*/
private float mFocusX = 0.f;
/*图片被拖动的Y距离*/
private float mFocusY = 0.f;

/*绘制涂鸦的原图*/
private Bitmap mSourceBitmap;
/*每一笔手写路径*/
private Path mCurrentPath;
/*每一笔路径的对象*/
private GraffitiPath mGraffitiPath;
/*保存涂鸦操作,撤销使用*/
private List<GraffitiPath> mPathList = new CopyOnWriteArrayList<GraffitiPath>();
/*用于绘制涂鸦后的图片*/
private Canvas mBitmapCanvas;

二、初始化图片

2.1、图片加载到内存中是以bitmap的形式存在,将一张bitmap显示到view上,需要根据view的宽高计算图片的宽高,如果图片还想居中显示,还需要平移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* @Description:初始化预览图,居中显示。
*/
private void initBitmap(Canvas canvas) {
if (mSourceBitmap != null) {
//重置当前Matrix(将当前Matrix重置为单位矩阵)
mMatrix.reset();
//获取图片实际宽高
int bitmapWidth = mSourceBitmap.getWidth();
int bitmapHeight = mSourceBitmap.getHeight();
// mWidth为控件宽,产品要求:将图片宽度充满,高度等比缩放。
float ratio = mWidth / (bitmapWidth * 1.0f);
mMatrix.postScale(ratio, ratio);
// mHeight为控件高,在纵坐标方向上进行偏移,以保证图片居中显示
float translateY = (mHeight - (bitmapHeight * ratio)) / 2f;
mMatrix.postTranslate(0, translateY);
//记录图片在矩阵上的纵向偏移值
mTotalTranslateY = translateY;
//记录图片在矩阵上的总缩放比例
mTotalScale = mInitScale = ratio;
//当前图片的宽
mCurrentBitmapwWidth = bitmapWidth * mInitScale;
//当前图片的高
mCurrentBitmapHeight = bitmapHeight * mInitScale;
//绘制图片
canvas.drawBitmap(mSourceBitmap, mMatrix, null);
//计算图片的四个顶点坐标
computeBoundry(mTotalTranslateX, mTotalTranslateY)
}
}

/**
* @param totalTranslateX 图片在矩阵上的横向偏移值
* @param totalTranslateY 图片在矩阵上的纵向偏移值
* @Description:计算顶点坐标
*/
private void computeBoundry(float totalTranslateX, float totalTranslateY) {
//图片左上角X坐标
mBitmapLeftTopX = mBitmapLeftTopX + totalTranslateX;
//图片左上角Y坐标
mBitmapLeftTopY = mBitmapLeftTopY + totalTranslateY;
//图片右上角X坐标
mBitmapRightTopX = mBitmapLeftTopX + mCurrentBitmapwWidth;
//图片左下角Y坐标
mBitmapLetfBottomY = mBitmapLeftTopY + mCurrentBitmapHeight;
}
  • 以上注释已经非常清楚了,唯一需要注意:要弄明白 Matrix 中 pre和post,关于它俩的区别:pre是倒序,post是正序。但是为了避免出错,笔者推荐只使用其中一种,而我更喜欢使用post所表达的逻辑。

三、图片的缩放和拖动以及涂鸦功能

3.1、画笔状态下,双指缩放和拖动

  • 双指缩放和拖动。
  • 双指缩放和拖动要领:双指的缩放和拖动是同时进行(这里的同时进行主要是指在onDraw方法内- 属于同一种情况)不可分开,原因是:只要俩根手指放在屏幕上,必然会触发缩放和拖动,基本不存在只缩放或者只拖动,不存在并不是没有,只是很少。所以在双指触摸在屏幕上时,我们认为缩放和拖动是同时进行的。假若在onDraw内把双指缩放和拖动分开处理,会因为驱动层返回给view的频率导致onDraw的频繁调用,从而出现UI晃动的感觉。虽然onDraw的处理是同一个方法,但是缩放和拖动的计算却是分开计算的。

  3.1.1、要实现缩放功能我们需要注意:缩放倍数,基于某点进行缩放
    3.1.1.1、对于缩放倍数:我们可以自己计算,也可以借助ScaleGestureDetector类,而我使用SDK提供(简单,高效,体验也好),以下是主要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @Description:缩放控制器
*/
private ScaleGestureDetector mScaleDetector;
mScaleDetector = new ScaleGestureDetector(mContext, new ScaleListener());

/**
* @Description:初始化预览图,居中显示。
*/
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (mCurrentStatus == STATUS_MOVE) {
// scale change since previous event
mTotalScale *= detector.getScaleFactor();//mTotalScale:记录图片总缩放比例
// Don't let the object get too small or too large.
mTotalScale = Math.max(mInitScale, Math.min(mTotalScale, mInitScale * 4));
//scaledRatio是用来在图片缩放时,计算位移的,如果图片没有缩放,scaledRatio始终为1,避免错误计算。
if( mTotalScale == mInitScale * 4){
scaledRatio = 1;
}
}
return true;
}
}

    3.1.1.2、对于基于某点进行缩放:屏幕内时基于控件中心点缩放,屏幕外时基于俩指中心点缩放,即基点的坐标不变,类比手机地图放大。因为android的缩放都是基于原点坐标的,所以缩放以后为了使用户体验正常,我们还要对图片进行移动,图片移动的距离和基点以及图片的左上角位置有关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
/*用于图片移动*/
private int mLastPointerCount;
private float mMoveLastX;
private float mMoveLastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
mScaleDetector.onTouchEvent(event);
float xTranslate = 0, yTranslate = 0;
// 拿到触摸点的个数
final int pointerCount = event.getPointerCount();
// 得到多个触摸点的x与y均值
for (int i = 0; i < pointerCount; i++) {
xTranslate += event.getX(i);
yTranslate += event.getY(i);
}
xTranslate = xTranslate / pointerCount;
yTranslate = yTranslate / pointerCount;
/**
* 每当触摸点发生变化时,重置mLasX , mLastY
*/
if (pointerCount != mLastPointerCount) {
mMoveLastX = xTranslate;
mMoveLastY = yTranslate;
}
mLastPointerCount = pointerCount;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
//双指为移动
if (event.getPointerCount() == 2) {
centerPointBetweenFingers(event);
mCurrentStatus = STATUS_CHANGE;
}
if (mCurrentStatus == STATUS_CHANGE) {
float dX = xTranslate - mMoveLastX;
float dY = yTranslate - mMoveLastY;
//缩放后的图片宽度大于控件宽度时
if (mCurrentBitmapwWidth > mWidth) {
//只有在图片可左右移动时,增加x
if ((dX > 0 && mBitmapLeftTopX < 0) || (dX < 0 && mBitmapRightTopX >
mWidth)) {
mFocusX = dX;
}
}
//缩放后的图片高度大于控件宽度时
if (mCurrentBitmapHeight > mHeight) {
//只有在图片可上下移动时,增加y
if ((dY > 0 && mBitmapLeftTopY < 0) || (dY < 0 && mBitmapLetfBottomY >
mHeight)) {
mFocusY = dY;
}
}
mMoveLastX = xTranslate;
mMoveLastY = yTranslate;
}
break;
case MotionEvent.ACTION_CANCEL:
mLastPointerCount = 0;
break;
default:
break;

}
invalidate();
return true;
}

@Override
protected void onDraw(Canvas canvas) {
canvas.save();
switch (mCurrentStatus) {
case STATUS_INIT:
initBitmap(canvas);
break;
case STATUS_CHANGE:
change(canvas);
break;
}
canvas.restore();

}

//避免float精度损失引起误差
float tatalScale = .0f;
/**
* @Description:对图片进行缩放和移动。
*/
private void change(Canvas canvas) {
mMatrix.reset();
//将图片按总缩放比例进行缩放
mMatrix.postScale(mTotalScale,mTotalScale);
//图片变化后的宽度
float scaledWidth = mSourceBitmap.getWidth() * mTotalScale;
//图片变化后的高度
float scaledHeight = mSourceBitmap.getHeight() * mTotalScale;
//当前图片的宽度
mCurrentBitmapwWidth = scaledWidth;
//当前图片的高度
mCurrentBitmapHeight = scaledHeight;
// 缩放后对图片进行偏移,以保证缩放后中心点位置不变
float translateX;
float translateY;
//缩放后的图片宽度小于的控件的宽度时,x基于控件中心缩放
if (scaledWidth < mWidth) {
translateX = (mWidth - scaledWidth) / 2f;
}
else {
//推到过程:假设被放大的图片是一个矩形,左上角坐标为x0,y0,基点为x1,y1,图形被放大的倍数为q,求放大后的左上角坐标为x2,y2,现在我们要求这个x2,y2。根据图形可以得出公式:
// [(x0 - x2) + (x1 - x0)] / (x1 -x0) = q,然后就可以求出坐标x2的值,同理可以求出y2。x2和y2即图片需要移动的距离。
translateX = mTotalTranslateX * scaledRatio + mCenterPointX * (1 - scaledRatio) + mFocusX;
//避免float的精度损失引起误差
if(tatalScale == mTotalScale){
translateX = mTotalTranslateX + mFocusX;
}
// 进行边界检查,不允许将图片拖出边界
if (translateX > 0) {
//x方向上,左边界检查
translateX = 0;
} else if (scaledWidth - mWidth < Math.abs(translateX)) {
//x方向上,右边界检查
translateX = mWidth - scaledWidth;
}

}
//缩放后的图片高度小于控件的高度时,y基于控件中心缩放
if (scaledHeight < mHeight) {
translateY = (mHeight - scaledHeight) / 2f;
}
else {
translateY = mTotalTranslateY * scaledRatio + mCenterPointY * (1 - scaledRatio) + mFocusY;
//避免float的精度损失引起误差
if(tatalScale == mTotalScale){
translateY = mTotalTranslateY + mFocusY;
}
// 进行边界检查,不允许将图片拖出边界
if (translateY > 0) {
//y方向上,上边界检查
translateY = 0;
} else if (scaledHeight - mHeight < Math.abs(translateY)) {
//y方向上,下边界检查
translateY = mHeight - scaledHeight;
}

}
mMatrix.postTranslate(translateX,translateY);
mTotalTranslateX = translateX;
mTotalTranslateY = translateY;
//避免float精度损失引起误差
tatalScale = mTotalScale;
//绘制
canvas.drawBitmap(mSourceBitmap, mMatrix, null);
//截取
canvas.clipRect(mTotalTranslateX, mTotalTranslateY, mTotalTranslateX +
mCurrentBitmapwWidth, mTotalTranslateY + mCurrentBitmapHeight);

(mTotalScale, mTotalTranslateX, mTotalTranslateY);
}

/**
* 记录两指同时放在屏幕上时,中心点的横坐标值
*/
private float mCenterPointX;

/**
* 记录两指同时放在屏幕上时,中心点的纵坐标值
*/
private float mCenterPointY;

/**
* 计算两个手指之间中心点的坐标。
*
* @param event
*/
private void centerPointBetweenFingers(MotionEvent event) {
float xPoint0 = event.getX(0);
float yPoint0 = event.getY(0);
float xPoint1 = event.getX(1);
float yPoint1 = event.getY(1);
mCenterPointX = (xPoint0 + xPoint1) / 2;
mCenterPointY = (yPoint0 + yPoint1) / 2;
}

  • 双指缩放拖动已经解析完了,再来看看画笔涂鸦的功能,涂鸦功能可分以下几个步骤解析

  3.1.2、画笔涂鸦
    3.1.2.1、初始化画笔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @Description:初始化画笔
*/
private void initPaint() {
mPaint = new Paint();
//画笔宽度:5f
mPaint.setStrokeWidth(5f);
//画笔颜色:红色
mPaint.setColor(Color.RED);
//画笔样式:线
mPaint.setStyle(Paint.Style.STROKE);
//消除锯齿
mPaint.setAntiAlias(true);
//设置笔刷的样式:圆
mPaint.setStrokeJoin(Paint.Join.ROUND);
mPaint.setStrokeCap(Paint.Cap.ROUND);
}

    3.1.2.2、画笔绘制及保存画笔路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/*用于贝塞尔曲线绘制*/
private float mDrawlastX;
private float mDrawlastY;
private float mTouchX;
private float mTouchY;

@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mGraffitiPath = new GraffitiPath();
//画笔只在图片范围内涂鸦
if (x >= mBitmapLeftTopX && x <= mBitmapRightTopX && y >= mBitmapLeftTopY
&& y < mBitmapLetfBottomY) {
mTouchX = mDrawlastX = x;
mTouchY = mDrawlastY = y;
mCurrentPath = new Path();
//移动到当前位置
mCurrentPath.moveTo(transformX(event.getX()), transformY(event.getY()));
}
case MotionEvent.ACTION_MOVE:
mCurrentStatus = STATUS_HANDWRITING;
//画笔只在图片范围内涂鸦
if (x >= mBitmapLeftTopX && x <= mBitmapRightTopX && y >= mBitmapLeftTopY
&& y < mBitmapLetfBottomY) {
if (mCurrentPath != null) {
mDrawlastX = mTouchX;
mDrawlastY = mTouchY;
mTouchX = event.getX();
mTouchY = event.getY();
//贝塞尔曲线绘制:mTouchY控制点为上一次touch位置,结束点为移动距离的一半。
mCurrentPath.quadTo(transformX(mDrawlastX), transformY(mDrawlastY),
(transformX(mDrawlastX) + transformX(mTouchX)) / 2, (transformY
(mDrawlastY) + transformY(mTouchY)) / 2);
//按照与服务端约定的格式保存画笔路径
mGraffitiPath.pathData.append(transformX(x) + ",");
mGraffitiPath.pathData.append(transformY(y) + ",");
mGraffitiPath.pathData.append(";");
}
}
//产品需求:单指涂鸦时,没有抬起动作,直接换成双指时撤销本次涂鸦路径
if (event.getPointerCount() == 2) {
mCurrentPath = null;
mGraffitiPath = null;

}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (mBimapStatus == STATUS_EDIT && mCurrentStatus == STATUS_HANDWRITING) {
//保存路径到集合中,撤销使用
if (mGraffitiPath != null && mGraffitiPath.pathData != null) {
int len = mGraffitiPath.pathData.length();
if (len > 0) {
mGraffitiPath.path = mCurrentPath;
mGraffitiPath.pathData.delete(len - 1, len);
mGraffitiPath.pathData.append("&");
mBitmapCanvas.drawPath(mCurrentPath, mPaint);
mPathList.add(mGraffitiPath);
}
}
}
break;
default:
break;

}
invalidate();
return true;
}

@Override
protected void onDraw(Canvas canvas) {
canvas.save();
switch (mCurrentStatus) {
case STATUS_HANDWRITING:
handWriting(canvas, STATUS_HANDWRITING);
break;
case STATUS_CLEAR:
handWriting(canvas, STATUS_CLEAR);
break;
case STATUS_UNDO:
handWriting(canvas, STATUS_UNDO);
break;
}
canvas.restore();

}

/**
* @Descripiton:手绘
*/
private void handWriting(Canvas canvas, int Type) {
mMatrix.reset();
// 将图片按总缩放比例进行缩放
mMatrix.postScale(mTotalScale, mTotalScale);
// 缩放后对图片进行偏移,以保证缩放后中心点位置不变
mMatrix.postTranslate(mTotalTranslateX, mTotalTranslateY);
canvas.drawBitmap(mSourceBitmap, mMatrix, null);
canvas.clipRect(mTotalTranslateX, mTotalTranslateY, mTotalTranslateX +
mCurrentBitmapwWidth, mTotalTranslateY + mCurrentBitmapHeight);
//给canvas必须设置matrix,不然canvas会按初始化的方式去绘制
canvas.setMatrix(mMatrix);
switch (Type) {
case STATUS_HANDWRITING:
//绘制
for (GraffitiPath path : mPathList) {
canvas.drawPath(path.path, mPaint);
}
if (mCurrentPath != null) {
canvas.drawPath(mCurrentPath, mPaint);
}
break;
case STATUS_UNDO:
//撤销
for (GraffitiPath path : mPathList) {
canvas.drawPath(path.path, mPaint);
}
break;
case STATUS_CLEAR:
//清空的时候就不再绘制了
break;
default:
break;

}
}

3.2、预览状态下,双指缩放,单指双指都可以拖动

  • 只要掌握画笔状态下,双指缩放和拖动,那么预览状态下,双指缩放,单指双指都可以拖动的功能就会so easy,这里就不再给出解析

  • 关于涂鸦功能,以上的注视都写的十分清楚,仔细阅读即可;关于功能上的实现已经完成,性能上暂且没发现什么问题,如果发现会及时修正。

  • 上面的代码只体现了功能性代码的实现过程,而源码中因为涂鸦和缩放的耦合以及业务场景的需要会有所不同,以下是源码下载地址,如有兴趣可以去看看。笔者水平有限,难免出现错误,如有发现还望不吝赐教,可以发邮件到我邮箱,我会及时回复和修改。

源码点击下载