Android之canvas中rotate()、translate()、save()、restore()

摘要

对于自定义View,Canvas是一个很重要的概念,对rotate()和translate()两个方法的理解一直有偏差,特此记录,参考此文章

首先看一个需求,显示一个竖向显示的TextView。

第一反应是让画布旋转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class RotateTextView extends TextView {
public RotateTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.rotate(90);
super.onDraw(canvas);
}
}
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.xiongcen.myapplication.test.RotateTextView
android:layout_width="100dp"
android:layout_height="200dp"
android:text="Hello World!" />
</RelativeLayout>

在绘制TextView之前把画布顺时针旋转90°。

打开开发者选项-显示布局边界,可以看到

文字并没有显示出来。


首先要明确一点,Canvas#rotate(float degrees)这个方法的旋转中心是坐标的原点。

该方法从字面上来看是旋转“画布”,但我们最好理解成旋转的是画布的坐标轴。

所以其实真正顺时针旋转90°的情况如下所示:

“画控件的画布的区域”:在xml中添加的控件RotateTextView中设置的layout_widths和layout_height这两个属性组成的矩形。原点的位置要按照控件所在的ViewGroup来决定,比如这个控件在LinearLayout里面,那么就看LinearLayout的orientation是vertical还是horizontal;如果这个控件在RelativeLayout里面,那么就看这个控件他在父容器的左边、右边、等等。

“旋转坐标系之后画控件的实际区域”:旋转后的坐标系的x,y轴正向的交集的区域,不管坐标系怎么转,控件都是画在x,y数值都为正的那个区间里面的

通过上图所示,可以看出坐标系旋转之后,实际画控件的区域并没有画布,所以也就什么都画不出来。可以看到布局边界显示了,但并没有任何内容。

所以我们应该如何实现一个竖直的TextView。

按照之前的结论,我们要把控件画在画布上,就要让我们的坐标系的x,y正向区域在”画控件的画布区域“上。

这个时候,我们就要用到Canvas#translate(float dx, float dy)方法。这个方法的作用就是移动画图的坐标系的原点

先说明,translate()方法是在x轴还是y轴移动的距离是按照“旋转坐标系之后画控件的实际区域“所标注的xy轴指向。

所以我们只要让原点的位置向右移动,让“旋转坐标系之后画控件的实际区域”向右移动,移动“画控件的画布的区域”的宽度就可以了。

1
2
3
4
5
6
@Override
protected void onDraw(Canvas canvas) {
canvas.rotate(90);
canvas.translate(0, -getWidth());
super.onDraw(canvas);
}

效果如下图:

接下来看Canavs#rotate(float degrees, float px, float py)方法:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Preconcat the current matrix with the specified rotation.
*
* @param degrees The amount to rotate, in degrees
* @param px The x-coord for the pivot point (unchanged by the rotation)
* @param py The y-coord for the pivot point (unchanged by the rotation)
*/
public final void rotate(float degrees, float px, float py) {
translate(px, py);
rotate(degrees);
translate(-px, -py);
}

假设我们调用Canvas#rotate(90,250,250);

如图所示:

黄色区域:画布初始位置,原点为(0,0);红色的xy轴;

绿色区域:画布原点(0,0)移动至(250,250)的位置;

蓝色区域:画布顺时针旋转90°的位置;桃红色的x1y1轴;

空白区域:画布原点(0,0)移回至(-250,-250)的位置(使用桃红色箭头指示);形成黑色的新的xy轴;

所以当我们自定义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
public class RotateArrow extends View {
private Paint mLinePaint = new Paint();
private Paint mBackgroundPaint = new Paint();
private int width = 500;
private int height = 500;
public RotateArrow(Context context, AttributeSet attrs) {
super(context, attrs);
mLinePaint.setStrokeWidth(4);
mLinePaint.setColor(Color.RED);
mBackgroundPaint.setColor(Color.GRAY);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawRect(0, 0, width, height, mBackgroundPaint);
canvas.rotate(90, width / 2, height / 2);
canvas.drawLine(width / 2, 0, 0, height / 2, mLinePaint);
canvas.drawLine(width / 2, 0, width, height / 2, mLinePaint);
canvas.drawLine(width / 2, 0, width / 2, height, mLinePaint);
canvas.drawCircle(width - 100, height - 100, 30, mLinePaint);
}
}

drawLine()和drawCircle()方法画出来的线和圆都是基于最新的黑色的xy轴。

所以效果如下:

如果我们修改RotateArrow#onDraw()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawRect(0, 0, width, height, mBackgroundPaint);
canvas.save();
canvas.rotate(90, width / 2, height / 2);
canvas.drawLine(width / 2, 0, 0, height / 2, mLinePaint);
canvas.drawLine(width / 2, 0, width, height / 2, mLinePaint);
canvas.drawLine(width / 2, 0, width / 2, height, mLinePaint);
canvas.restore();
canvas.drawCircle(width - 100, height - 100, 30, mLinePaint);
}

drawLine()方法画出来的线是基于最新的黑色的xy轴;
drawCircle()方法画出来的圆是基于save()方法调用之前的红色的xy轴;

效果如下:

所以很明显:

Canvas#save():用来保存Canvas的状态。

Canvas#restore():用来恢复Canvas之前保存的状态(可以想成是保存坐标轴的状态),防止save()方法代码之后对Canvas执行的操作,继续对后续的绘制会产生影响。

为了对save()和translate()两个方法有深刻认识,请看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
protected void onDraw(Canvas canvas) {
/*
* 保存并裁剪画布填充黄色
*/
int saveID1 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 300, mViewHeight / 2F - 300, mViewWidth / 2F + 300, mViewHeight / 2F + 300);
canvas.drawColor(Color.YELLOW);
/*
* 保存并裁剪画布填充绿色
*/
int saveID2 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200);
canvas.drawColor(Color.GREEN);
/*
* 保存画布并旋转后绘制一个蓝色的矩形
*/
int saveID3 = canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.rotate(5);
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
}

效果如下图:

save()是依靠stack栈来进行,此时在Canvas内部会有这样的一个Stack栈:

Canvas会默认保存一个底层的控件给我们绘制一些东西,当我们没有调用save方法时所有的绘图操作都在这个Default Stack ID中进行,每当我们调用一次save就会往Stack中存入一个ID,将其后所有的操作都在这个ID所指向的空间进行直到我们调用restore方法还原操作。

如果我们要继续绘制东西,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* 保存并裁剪画布填充黄色
*/
int saveID1 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 300, mViewHeight / 2F - 300, mViewWidth / 2F + 300, mViewHeight / 2F + 300);
canvas.drawColor(Color.YELLOW);
/*
* 保存并裁剪画布填充绿色
*/
int saveID2 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200);
canvas.drawColor(Color.GREEN);
/*
* 保存画布并旋转后绘制一个蓝色的矩形
*/
int saveID3 = canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.rotate(5);
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
mPaint.setColor(Color.CYAN);
canvas.drawRect(mViewWidth / 2F, mViewHeight / 2F, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint);

效果如下图:

我们在saveID3之后又画了一个青色的矩形,很明显,这段代码是在saveID3所标识的空间中绘制的,因此其必然会受到saveID3的约束旋转,并且!!!这个矩形除了被旋转,还被clip了,也就是说saveID1、saveID2也同时对其产生了影响,此时我们再次尝试在saveID2绘制完我们想要的东西后将其还原

1
2
3
4
5
6
7
/*
* 保存并裁剪画布填充绿色
*/
int saveID2 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200);
canvas.drawColor(Color.GREEN);
canvas.restore();

同时将青色的矩形变大:

1
canvas.drawRect(mViewWidth / 2F, mViewHeight / 2F, mViewWidth / 2F + 400, mViewHeight / 2F + 400, mPaint);

效果如下图:

很明显,saveID2已经不再对下面的saveID3起作用了,也就是说当我们调用canvas.restore()后标志着上一个save操作的结束或者说回滚了。

同理,我们再把saveID1也restore:

1
2
3
4
5
6
7
/*
* 保存并裁剪画布填充黄色
*/
int saveID1 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 300, mViewHeight / 2F - 300, mViewWidth / 2F + 300, mViewHeight / 2F + 300);
canvas.drawColor(Color.YELLOW);
canvas.restore();

这时saveID3将彻底不再受前面操作的影响:

如果我们在绘制青色的矩形之前将saveID3也还原:

1
2
3
4
5
6
7
8
/*
* 保存画布并旋转后绘制一个蓝色的矩形
*/
int saveID3 = canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.rotate(5);
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();

那么这个青色的矩形将会被绘制在Default Stack ID上而不受其他save状态的影响:

每当我们调用restore还原Canvas,对应的save栈空间就会从Stack中弹出去,Canvas提供了getSaveCount()方法来为我们提供查询当前栈中有多少save的空间。

还有一点需要注意,save(@Saveflags int saveFlags)方法所标注的类型来保证画布保存的值,如果save一个flag为MATRIX_SAVE_FLAG,画布做的却是clipRect操作,那么就算调用restore,依然无法回滚。
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
/*
* 保存并裁剪画布填充黄色
*/
int saveID1 = canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 300, mViewHeight / 2F - 300, mViewWidth / 2F + 300, mViewHeight / 2F + 300);
canvas.drawColor(Color.YELLOW);
canvas.restore();
/*
* 保存并裁剪画布填充绿色
*/
int saveID2 = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200);
canvas.drawColor(Color.GREEN);
canvas.restore();
/*
* 保存画布并旋转后绘制一个蓝色的矩形
*/
int saveID3 = canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.rotate(5);
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
mPaint.setColor(Color.CYAN);
canvas.drawRect(mViewWidth / 2F, mViewHeight / 2F, mViewWidth / 2F + 400, mViewHeight / 2F + 400, mPaint);

saveID1依然对青色矩形起作用:

参考

  1. Android Canvas编程:对rotate()和translate()两个方法的研究

  2. 自定义控件