转载爱哥自定义View系列--Canvas详解

转载爱哥自定义View系列--Canvas详解

上面所罗列出来的各种drawXXX方法就是Canvas中定义好的能画什么的方法(drawPaint除外),除了各种基本型比如矩形圆形椭圆直曲线外Canvas也能直接让我们绘制各种图片以及颜色等等,但是Canvas真正屌的我觉得不是它能画些什么,而是对画布的各种活用,上一节最后的一个例子大家已经粗略见识了变换Canvas配合save和restore方法给我们绘制图形带来的极大便利,事实上Canvas的活用远不止此,在讲Canvas之前,我想先给大家说说Canvas中非常屌毛而且很有个性的一个方法:

drawBitmapMesh(Bitmap bitmap, int meshWidth, int meshHeight, float[] verts, int vertOffset, int[] colors, int colorOffset, Paint paint)

drawBitmapMesh是个很屌毛的方法,为什么这样说呢?因为它可以对Bitmap做几乎任何改变,是的,你没听错,是任何,几乎无所不能,这个屌毛方法我曾一度怀疑谷歌那些逗比为何将它屈尊在Canvas下,因为它对Bitmap的处理实在在强大了。上一节我们在讲到Matrix的时候说过Matrix可以对我们的图像做多种变换,实际上drawBitmapMesh也可以,只不过需要一点计算,比如我们可以使用drawBitmapMesh来模拟错切skew的效果:

public class BitmapMeshView extends View {
private static final int WIDTH = 19;// 横向分割成的网格数量
private static final int HEIGHT = 19;// 纵向分割成的网格数量
private static final int COUNT = (WIDTH + 1) * (HEIGHT + 1);// 横纵向网格交织产生的点数量 private Bitmap mBitmap;// 位图资源 private float[] verts;// 交点的坐标数组 public BitmapMeshView(Context context, AttributeSet attrs) {
super(context, attrs); // 获取位图资源
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.gril); // 实例化数组
verts = new float[COUNT * 2]; /*
* 生成各个交点坐标
*/
int index = 0;
float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}
} /**
* 将计算后的交点坐标存入数组
*
* @param fx
* x坐标
* @param fy
* y坐标
* @param index
* 标识值
*/
private void setXY(float fx, float fy, int index) {
verts[index * 2 + 0] = fx;
verts[index * 2 + 1] = fy;
} @Override
protected void onDraw(Canvas canvas) {
// 绘制网格位图
canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, verts, 0, null, 0, null);
}
}

转载爱哥自定义View系列--Canvas详解

实现过程也非常非常简单

其他的我就不说了,关键代码就一段:

/*
* 生成各个交点坐标
*/
int index = 0;
float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}

这段代码生成了200个点的坐标数据全部存入verts数组,verts数组中,偶数位表示x轴坐标,奇数位表示y轴坐标,最终verts数组中的元素构成为:[x,y,x,y,x,y,x,y,x,y,x,y,x,y………………]共200 * 2=400个元素,为什么是400个?如果你不是蠢13的话一定能计算过来。那么现在我们一定很好奇,drawBitmapMesh到底是个什么个意思呢?,其实drawBitmapMesh的原理灰常简单,它按照meshWidth和meshHeight这两个参数的值将我们的图片划分成一定数量的网格,比如上面我们传入的meshWidth和meshHeight均为19,意思就是把整个图片横纵向分成19份:

转载爱哥自定义View系列--Canvas详解

横纵向19个网格那么意味着横纵向分别有20条分割线对吧,这20条分割线交织又构成了20 * 20个交织点

每个点又有x、y两个坐标……而drawBitmapMesh的verts参数就是存储这些坐标值的,不过是图像变化后的坐标值,什么意思?说起来有点抽象,借用国外大神的两幅图来理解:

转载爱哥自定义View系列--Canvas详解

如上图,黄色的点是使用mesh分割图像后分割线的交点之一,而drawBitmapMesh的原理就是通过移动这些点来改变图像:

转载爱哥自定义View系列--Canvas详解

如上图,移动黄色的点后,图像被扭曲改变,你能想象在一幅刚画好的油画上有手指尖一抹的感觉么?油画未干,手指抹过的地方必将被抹得一塌糊涂,drawBitmapMesh的原理就与之类似,只不过我们不常只改变一点,而是改变大量的点来达到效果,而参数verts则存储了改变后的坐标,drawBitmapMesh依据这些坐标来改变图像,如果上面的代码中我们不将每行的x轴坐标进行平移而是单纯地计算了一下均分后的各点坐标:

/*
* 生成各个交点坐标
*/
int index = 0;
// float multiple = mBitmap.getWidth();
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT;
for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH;
// float fx = mBitmap.getWidth() * x / WIDTH + ((HEIGHT - y) * 1.0F / HEIGHT * multiple);
setXY(fx, fy, index);
index += 1;
}
}

你会发现图像没有任何改变,为什么呢?因为上面我们说过,verts表示了图像变化后各点的坐标,而点坐标的变化是参照最原始均分后的坐标点,也就是图:

转载爱哥自定义View系列--Canvas详解

中的各个交织点,在此基础上形成变化,比如我们最开始的错切效果,原理很简单,我们这里把图像分成了横竖20条分割线(实际上错切变换只需要四个顶点即可,这里我只作点稍复杂的演示),我们只需将第一行的点x轴向上移动一定距离,而第二行的点移动的距离则比第一行点稍短,依次类推即可,每行点移动的距离我们通过

(HEIGHT - y) * 1.0F / HEIGHT * multiple

来计算,最终形成错切的效果

drawBitmapMesh不能存储计算后点的值,每次调用drawBitmapMesh方法改变图像都是以基准点坐标为参考的,也就是说,不管你执行drawBitmapMesh方法几次,只要参数没改变,效果不累加。

drawBitmapMesh可以做出很多很多的效果,比如类似放大镜的:

/*
* 生成各个交点坐标
*/
int index = 0;
float multipleY = mBitmap.getHeight() / HEIGHT;
float multipleX = mBitmap.getWidth() / WIDTH;
for (int y = 0; y <= HEIGHT; y++) {
float fy = multipleY * y;
for (int x = 0; x <= WIDTH; x++) {
float fx = multipleX * x; setXY(fx, fy, index); if (5 == y) {
if (8 == x) {
setXY(fx - multipleX, fy - multipleY, index);
}
if (9 == x) {
setXY(fx + multipleX, fy - multipleY, index);
}
}
if (6 == y) {
if (8 == x) {
setXY(fx - multipleX, fy + multipleY, index);
}
if (9 == x) {
setXY(fx + multipleX, fy + multipleY, index);
}
} index += 1;
}
}

这时我们将图片眼睛附近的四个点外移到临近的四个点上,图像该区域就会被像放大一样:

转载爱哥自定义View系列--Canvas详解

太恶心了……我们借助另外一个例子来更好地理解drawBitmapMesh,这个例子与API DEMO类似,我只是参考了国外大神的效果给他加上了一些标志点和位移线段来更好地展示drawBitmapMesh做了什么:

public class BitmapMeshView2 extends View {
private static final int WIDTH = 9, HEIGHT = 9;// 分割数
private static final int COUNT = (WIDTH + 1) * (HEIGHT + 1);// 交点数 private Bitmap mBitmap;// 位图对象 private float[] matrixOriganal = new float[COUNT * 2];// 基准点坐标数组
private float[] matrixMoved = new float[COUNT * 2];// 变换后点坐标数组 private float clickX, clickY;// 触摸屏幕时手指的xy坐标 private Paint origPaint, movePaint, linePaint;// 基准点、变换点和线段的绘制Paint public BitmapMeshView2(Context context, AttributeSet set) {
super(context, set);
setFocusable(true); // 实例画笔并设置颜色
origPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
origPaint.setColor(0x660000FF);
movePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
movePaint.setColor(0x99FF0000);
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
linePaint.setColor(0xFFFFFB00); // 获取位图资源
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bt); // 初始化坐标数组
int index = 0;
for (int y = 0; y <= HEIGHT; y++) {
float fy = mBitmap.getHeight() * y / HEIGHT; for (int x = 0; x <= WIDTH; x++) {
float fx = mBitmap.getWidth() * x / WIDTH;
setXY(matrixMoved, index, fx, fy);
setXY(matrixOriganal, index, fx, fy);
index += 1;
}
}
} /**
* 设置坐标数组
*
* @param array
* 坐标数组
* @param index
* 标识值
* @param x
* x坐标
* @param y
* y坐标
*/
private void setXY(float[] array, int index, float x, float y) {
array[index * 2 + 0] = x;
array[index * 2 + 1] = y;
} @Override
protected void onDraw(Canvas canvas) { // 绘制网格位图
canvas.drawBitmapMesh(mBitmap, WIDTH, HEIGHT, matrixMoved, 0, null, 0, null); // 绘制参考元素
drawGuide(canvas);
} /**
* 绘制参考元素
*
* @param canvas
* 画布
*/
private void drawGuide(Canvas canvas) {
for (int i = 0; i < COUNT * 2; i += 2) {
float x = matrixOriganal[i + 0];
float y = matrixOriganal[i + 1];
canvas.drawCircle(x, y, 4, origPaint); float x1 = matrixOriganal[i + 0];
float y1 = matrixOriganal[i + 1];
float x2 = matrixMoved[i + 0];
float y2 = matrixMoved[i + 1];
canvas.drawLine(x1, y1, x2, y2, origPaint);
} for (int i = 0; i < COUNT * 2; i += 2) {
float x = matrixMoved[i + 0];
float y = matrixMoved[i + 1];
canvas.drawCircle(x, y, 4, movePaint);
} canvas.drawCircle(clickX, clickY, 6, linePaint);
} /**
* 计算变换数组坐标
*/
private void smudge() {
for (int i = 0; i < COUNT * 2; i += 2) { float xOriginal = matrixOriganal[i + 0];
float yOriginal = matrixOriganal[i + 1]; float dist_click_to_origin_x = clickX - xOriginal;
float dist_click_to_origin_y = clickY - yOriginal; float kv_kat = dist_click_to_origin_x * dist_click_to_origin_x + dist_click_to_origin_y * dist_click_to_origin_y; float pull = (float) (1000000 / kv_kat / Math.sqrt(kv_kat)); if (pull >= 1) {
matrixMoved[i + 0] = clickX;
matrixMoved[i + 1] = clickY;
} else {
matrixMoved[i + 0] = xOriginal + dist_click_to_origin_x * pull;
matrixMoved[i + 1] = yOriginal + dist_click_to_origin_y * pull;
}
}
} @Override
public boolean onTouchEvent(MotionEvent event) {
clickX = event.getX();
clickY = event.getY();
smudge();
invalidate();
return true;
}
}

运行后的效果如下:

转载爱哥自定义View系列--Canvas详解

大波妹子图上我们绘制了很多蓝色和红色的点,默认状态下,蓝色和红色的点是重合在一起的,两者间通过一线段连接,当我们手指在图片上移动时,会出现一个黄色的点,黄色的点代表我们当前的触摸点,而红色的点代表变换后的坐标点,蓝色的点代表基准坐标点:

转载爱哥自定义View系列--Canvas详解

可以看到越靠近触摸点的红点越向触摸点坍塌,红点表示当前变换后的点坐标,蓝点表示基准点的坐标,所有的变化都是参照蓝点进行的,这个例子可以很容易地理解drawBitmapMesh:

转载爱哥自定义View系列--Canvas详解

大波妹子揉啊揉~~~~揉啊揉~~~~

drawBitmapMesh参数中有个vertOffset,该参数是verts数组的偏移值,意为从第一个元素开始才对位图就行变化,这些大家自己去尝试下吧,还有colors和colorOffset,类似。

drawBitmapMesh说实话真心很屌,但是计算复杂确是个鸡肋,这么屌的一个方法被埋没其实是由原因可循的,高不成低不就,如上所示,有些变换我们可以使用Matrix等其他方法简单实现,但是drawBitmapMesh就要通过一些列计算,太复杂。那真要做复杂的图形效果呢,考虑到效率我们又会首选OpenGL……这真是一个悲伤的故事……无论怎样,请记住这位烈士一样的方法…………总有用处的

好了,真的要开始搞Canvas,开始搞了哦~~谁先上?

要学懂Canvas就要知道Canvas的本质是什么,那有盆友就会说了,麻痹你不是扯过无数次Canvas是画布么,难道又不是了?是,Canvas是画布,但是我们真的是在Canvas上画东西么?在前几节的一些例子中我们曾这样使用过Canvas:

Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.RED);

也就是说将Bitmap注入到Canvas中,尔后Canvas所有的操作都会在这个Bitmap上进行,如果,此时我们的界面中有一个ImageView,那么我们可以直接将绘制后的Bitmap显示出来

public class MainActivity extends Activity {
private ImageView ivMain; @Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); ivMain = (ImageView) findViewById(R.id.main_iv); Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(Color.RED); ivMain.setImageBitmap(bitmap);
}
}

我们只是简单地填充了一块红色色块,色块的大小由bitmap决定,更确切地说,这个Canvas的大小是由bitmap决定的,类似的方法我们在前几节的例子中也不少用到,这里就不多说了。除了我们自己去new一个Canvas外,我们更常获得Canvas对象的地方是在View的:

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}

在这里通过onDraw方法的参数传递我们可以获取一个Canvas对象,好奇的同学一定很想知道这个Canvas对象是如何来的,跟我们自己new的有何区别。事实上两者区别不大,最终都是new过来的,只是onDraw方法传过来的Canvas对象拥有一些绘制的上下文关联。

一个Canvas需要一个Bitmap来保存像素信息,你说不要行不行?当然可以,画得东西没法保存而已,既然没法保存那我画来还有何意义呢?isn't it?

Canvas所提供的各种方法根据功能来看大致可以分为几类,第一是以drawXXX为主的绘制方法,第二是以clipXXX为主的裁剪方法,第三是以scale、skew、translate和rotate组成的Canvas变换方法,最后一类则是以saveXXX和restoreXXX构成的画布锁定和还原,还有一些渣渣方法就不归类了。

绘制图形、变换锁定还原画布我们都在前面的一些code中使用过,那么什么叫裁剪画布呢?我们来看一段code:

public class CanvasView extends View {
public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
} @Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(0, 0, 500, 500);
canvas.drawColor(Color.RED);
}
}

这段代码灰常简单,我们在onDraw中将整个画布绘制成蓝色,然后我们在当前画布上从[0,0]为左端点开始裁剪出一块500x500大小的矩形,再次将画布绘制成红色,你会发现只有被裁剪的区域才能被绘制成红色:

转载爱哥自定义View系列--Canvas详解

是不是有点懂裁剪的意思了?不懂?没事,我们再画一个圆加深理解:

public class CanvasView extends View {
private Paint mPaint; public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs); mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.GREEN);
} @Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE);
canvas.clipRect(0, 0, 500, 500);
canvas.drawColor(Color.RED);
canvas.drawCircle(500, 600, 100, mPaint);
}
}

如代码所示,我们在以[500,600]为圆心绘制一个半径为100px的绿色圆,按道理来说,这个圆应该刚好与红色区域下方相切对吧,但是事实上呢我们见不到任何效果,为什么?因为如上所说,当前画布被“裁剪”了,只有500x500也就是上图中红色区域的大小了,如果我们所绘制的东西在该区域外部,即便绘制了你也看不到,这时我们稍增大圆的半径:

canvas.drawCircle(500, 600, 150, mPaint);

转载爱哥自定义View系列--Canvas详解

终于看到我们的圆“露”出来了~~现在你能稍微明白裁剪的作用了么?上面的代码中我们使用到了Canvas的

clipRect(int left, int top, int right, int bottom)

方法,与之类似的还有

clipRect(float left, float top, float right, float bottom)

除此之外还有两个与之对应的方法

clipRect(Rect rect)
clipRect(RectF rect)

Rect和RectF是类似的,只不过RectF中涉及计算的时候数值类型均为float型,两者均表示一块规则矩形。

public class CanvasView extends View {
private Rect mRect; public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mRect = new Rect(0, 0, 500, 500);
} @Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE); canvas.clipRect(mRect); canvas.drawColor(Color.RED);
}
}

如代码所示这样我们得到的结果跟上面的结果并无二致,蓝色的底,500x500大小的红色矩形,但是Rect的意义远不止于此,鉴于Rect类并不复杂,我就讲两个其比较重要的方法,我们稍微更改下我们的代码:

public class CanvasView extends View {
private Rect mRect; public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs);
mRect = new Rect(0, 0, 500, 500); mRect.intersect(250, 250, 750, 750);
} @Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE); canvas.clipRect(mRect); canvas.drawColor(Color.RED);
}
}

大家看到我在实例化了一个Rect后调用了intersect方法,这个方法的作用是什么?来看看效果先:

转载爱哥自定义View系列--Canvas详解

PS:黄色线框为后期加上的辅助线非程序生成

可以看到原先的红色区域变小了,这是怎么回事呢?其实intersect的作用跟我们之前学到的图形混合模式有点类似,它会取两个区域的相交区域作为最终区域,上面我们的第一个区域是在实例化Rect时确定的(0, 0, 500, 500),第二个区域是调用intersect方法时指定的(250, 250, 750, 750),这两个区域对应上图的两个黄色线框,两者相交的地方则为最终的红色区域,而intersect方法的计算方式是相当有趣的,它不是单纯地计算相交而是去计算相交区域最近的左上端点和最近的右下端点,不知道大家是否明白这个意思,我们来看Rect中的另一个union方法你就会懂,union方法与intersect相反,取的是相交区域最远的左上端点作为新区域的左上端点,而取最远的右下端点作为新区域的右下端点,比如:

mRect.union(250, 250, 750, 750);

运行后我们会看到如下结果:

转载爱哥自定义View系列--Canvas详解

是不是觉得不是我们想象中的那样单纯地两个区域相加?没事,好好体会,后面还有类似的。类似的方法Rect和RectF都有很多,效果都是显而易见的就不多说了,有兴趣大家可以自己去try。

说到这里会有很多童鞋会问,裁剪只是个矩形区域,如果我想要更多不规则的裁剪区域怎么办呢?别担心,Android必然也考虑到这样的情况,其提供了一个

clipPath(Path path)

方法给我们以Path的方式创建更多不规则的裁剪区域,在1/4讲PathEffect的时候我们曾对Path有所接触,但是依旧不了解

Path是android中用来封装几何学路径的一个类,因为Path在图形绘制上占的比重还是相当大的,这里我们先来学习一下这个Path,来看看其一些具体的用法:

public class PathView extends View {
private Path mPath;// 路径对象
private Paint mPaint;// 画笔对象 public PathView(Context context, AttributeSet attrs) {
super(context, attrs); /*
* 实例化画笔并设置属性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN); // 实例化路径
mPath = new Path(); // 连接路径到点[100,100]
mPath.lineTo(100, 100);
} @Override
protected void onDraw(Canvas canvas) {
// 绘制路径
canvas.drawPath(mPath, mPaint);
}
}

这里我们用到了Path的一个方法

lineTo(float x, float y)

该方法很简单咯,顾名思义将路径连接至某个坐标点,事实也是如此:

转载爱哥自定义View系列--Canvas详解

注意,当我们没有移动Path的点时,其默认的起点为画布的[0,0]点,当然我们可以通过

moveTo(float x, float y)

方法来改变这个起始点的位置:

// 实例化路径
mPath = new Path(); //移动点至[300,300]
mPath.moveTo(300, 300); // 连接路径到点[100,100]
mPath.lineTo(100, 100);

效果如下:

转载爱哥自定义View系列--Canvas详解

如果此时我们想闭合该曲线

// 实例化路径
mPath = new Path(); // 移动点至[300,300]
mPath.moveTo(100, 100); // 连接路径到点
mPath.lineTo(300, 100);
mPath.lineTo(400, 200);
mPath.lineTo(200, 200); // 闭合曲线
mPath.close();

转载爱哥自定义View系列--Canvas详解

那么有些朋友会问Path就只能光绘制这些单调的线段么?肯定不是!Path在绘制的方法中提供了许多XXXTo的方法来帮助我们绘制各类直线、曲线,例如,方法

quadTo(float x1, float y1, float x2, float y2)

可以让我们绘制二阶贝赛尔曲线,什么叫贝赛尔曲线?其实很简单,使用三个或多个点来确定的一条曲线,贝塞尔曲线在图形图像学中有相当重要的地位,Path中也提供了一些方法来给我们模拟低阶贝赛尔曲线。

贝塞尔曲线通用公式:

转载爱哥自定义View系列--Canvas详解

回到我们Path的quadTo方法,我们可以使用它来绘制一条曲线:

// 实例化路径
mPath = new Path(); // 移动点至[100,100]
mPath.moveTo(100, 100); // 连接路径到点
mPath.quadTo(200, 200, 300, 100);

看图说话:

转载爱哥自定义View系列--Canvas详解

其中quadTo的前两个参数为控制点的坐标,后两个参数为终点坐标,至于起点嘛……这么二的问题就别问了……是不是很简单?如果你这么认为那就太小看贝塞尔曲线了。在我们对Path有一定的了解后会使用Path和裁剪做个有趣的东西,接着看Path的三阶贝赛尔曲线:

cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)

与quadTo类似,前四个参数表示两个控制点,最后两个参数表示终点:

// 实例化路径
mPath = new Path(); // 移动点至[100,100]
mPath.moveTo(100, 100); // 连接路径到点
mPath.cubicTo(200, 200, 300, 0, 400, 100);

很好理解:

转载爱哥自定义View系列--Canvas详解

贝塞尔曲线是图形图像学中相当重要的一个概念,活用它可以得到很多很有意思的效果,比如,我在界面中简单模拟一下杯子中水消匿的效果:

转载爱哥自定义View系列--Canvas详解

当然你也可以反过来让模拟往杯子里倒水的效果~实现过程非常简单,说白了就是不断移动二阶曲线的控制点同时不断更改顶部各点的Y坐标,然后不断重绘:

public class WaveView extends View {
private Path mPath;// 路径对象
private Paint mPaint;// 画笔对象 private int vWidth, vHeight;// 控件宽高
private float ctrX, ctrY;// 控制点的xy坐标
private float waveY;// 整个Wave顶部两端点的Y坐标,该坐标与控制点的Y坐标增减幅一致 private boolean isInc;// 判断控制点是该右移还是左移 public WaveView(Context context, AttributeSet attrs) {
super(context, attrs); // 实例化画笔并设置参数
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setColor(0xFFA2D6AE); // 实例化路径对象
mPath = new Path();
} @Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 获取控件宽高
vWidth = w;
vHeight = h; // 计算控制点Y坐标
waveY = 1 / 8F * vHeight; // 计算端点Y坐标
ctrY = -1 / 16F * vHeight;
} @Override
protected void onDraw(Canvas canvas) {
/*
* 设置Path起点
* 注意我将Path的起点设置在了控件的外部看不到的区域
* 如果我们将起点设置在控件左端x=0的位置会使得贝塞尔曲线变得生硬
* 至于为什么刚才我已经说了
* 所以我们稍微让起点往“外”走点
*/
mPath.moveTo(-1 / 4F * vWidth, waveY); /*
* 以二阶曲线的方式通过控制点连接位于控件右边的终点
* 终点的位置也是在控件外部
* 我们只需不断让ctrX的大小变化即可实现“浪”的效果
*/
mPath.quadTo(ctrX, ctrY, vWidth + 1 / 4F * vWidth, waveY); // 围绕控件闭合曲线
mPath.lineTo(vWidth + 1 / 4F * vWidth, vHeight);
mPath.lineTo(-1 / 4F * vWidth, vHeight);
mPath.close(); canvas.drawPath(mPath, mPaint); /*
* 当控制点的x坐标大于或等于终点x坐标时更改标识值
*/
if (ctrX >= vWidth + 1 / 4F * vWidth) {
isInc = false;
}
/*
* 当控制点的x坐标小于或等于起点x坐标时更改标识值
*/
else if (ctrX <= -1 / 4F * vWidth) {
isInc = true;
} // 根据标识值判断当前的控制点x坐标是该加还是减
ctrX = isInc ? ctrX + 20 : ctrX - 20; /*
* 让“水”不断减少
*/
if (ctrY <= vHeight) {
ctrY += 2;
waveY += 2;
} mPath.reset(); // 重绘
invalidate();
}
}

除了上面的几个XXXTo外,Path还提供了一个

arcTo (RectF oval, float startAngle, float sweepAngle)

方法用来生成弧线,其实说白了就是从圆或椭圆上截取一部分而已 = =

// 实例化路径
mPath = new Path(); // 移动点至[100,100]
mPath.moveTo(100, 100); // 连接路径到点
RectF oval = new RectF(100, 100, 200, 200);
mPath.arcTo(oval, 0, 90);

效果如下图:

转载爱哥自定义View系列--Canvas详解

这里要注意哦,使用Path生成的路径必定都是连贯的,虽然我们使用arcTo绘制的是一段弧但其最终都会与我们的起始点[100,100]连接起来,如果你不想连怎么办?简单,强制让arcTo绘制的起点作为Path的起点不就是了?Path也提供了另一个重载方法:

arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo)

该方法只是多了一个布尔值,值为true时将会把弧的起点作为Path的起点:

mPath.arcTo(oval, 0, 90, true);

like below:

转载爱哥自定义View系列--Canvas详解

Path中除了上面介绍的几个XXXTo方法外还有一套rXXXTo方法:

rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
rLineTo(float dx, float dy)
rMoveTo(float dx, float dy)
rQuadTo(float dx1, float dy1, float dx2, float dy2)

这一系列rXXXTo方法其实跟上面的那些XXXTo差不多的,唯一的不同是rXXXTo方法的参考坐标是相对的而XXXTo方法的参考坐标始终是参照画布原点坐标,什么意思呢?举个简单的例子:

public class PathView extends View {
private Path mPath;// 路径对象
private Paint mPaint;// 画笔对象 public PathView(Context context, AttributeSet attrs) {
super(context, attrs); /*
* 实例化画笔并设置属性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5); // 实例化路径
mPath = new Path(); // 移动点至[100,100]
mPath.moveTo(100, 100); // 连接路径到点
mPath.lineTo(200, 200);
} @Override
protected void onDraw(Canvas canvas) {
// 绘制路径
canvas.drawPath(mPath, mPaint);
}
}

上述代码我们从点[100,100]开始连接点[200,200]构成了一条线段:

转载爱哥自定义View系列--Canvas详解

这个点[200,200]是相对于画布圆点坐标[0,0]而言的,这点大家应该好理解,如果我们换成

mPath.rLineTo(200, 200);

那么它的意思就是将会以[100,100]作为原点坐标,连接以其为原点坐标的坐标点[200,200],如果换算成一画布原点的话,实际上现在的[200,200]就是[300,300]了:

转载爱哥自定义View系列--Canvas详解

懂了么?而这个前缀r也就是relative(相对)的简写,so easy是么!头脑简单!

XXXTo方法可以连接Path中的曲线而Path提供的另一系列addXXX方法则可以让我们直接往Path中添加一些曲线,比如

addArc(RectF oval, float startAngle, float sweepAngle)

方法允许我们将一段弧形添加至Path,注意这里我用到了“添加”这个词汇,也就是说,通过addXXX方法添加到Path中的曲线是不会和上一次的曲线进行连接的:

public class PathView extends View {
private Path mPath;// 路径对象
private Paint mPaint;// 路径画笔对象 public PathView(Context context, AttributeSet attrs) {
super(context, attrs); /*
* 实例化画笔并设置属性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5); // 实例化路径
mPath = new Path(); // 移动点至[100,100]
mPath.moveTo(100, 100); // 连接路径到点
mPath.lineTo(200, 200); // 添加一条弧线到Path中
RectF oval = new RectF(100, 100, 300, 400);
mPath.addArc(oval, 0, 90);
} @Override
protected void onDraw(Canvas canvas) {
// 绘制路径
canvas.drawPath(mPath, mPaint);
}
}

转载爱哥自定义View系列--Canvas详解

如图和代码所示,虽然我们先绘制了由[100,100]到[200,200]的线段,但是在我们往Path中添加了一条弧线后该弧线并没与线段连接。除了addArc,Path还提供了一系列的add方法

addCircle(float x, float y, float radius, Path.Direction dir)
addOval(float left, float top, float right, float bottom, Path.Direction dir)
addRect(float left, float top, float right, float bottom, Path.Direction dir)
addRoundRect(float left, float top, float right, float bottom, float rx, float ry, Path.Direction dir)

这些方法和addArc有很明显的区别,就是多了一个Path.Direction参数,其他呢都大同小异,除此之外不知道大家还发现没有,addArc是往Path中添加一段弧,说白了就是一条开放的曲线,而上述几种方法都是一个具体的图形,或者说是一条闭合的曲线,Path.Direction的意思就是标识这些闭合曲线的闭合方向。那什么叫闭合方向呢?光说大家一定会蒙,有学习激情的童鞋看到后肯定会马上敲代码试验一下两者的区别,可是不管你如何改,单独地在一条闭合曲线上你是看不出所谓闭合方向的区别的,这时我们可以借助Canvas的另一个方法来简单地说明一下

drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint)

这个方法呢很简单沿着Path绘制一段文字,参数也是一看就该懂得了不多说。Path.Direction只有两个常量值CCW和CW分别表示逆时针方向闭合和顺时针方向闭合,我们来看一段代码

public class PathView extends View {
private Path mPath;// 路径对象
private Paint mPaint;// 路径画笔对象
private TextPaint mTextPaint;// 文本画笔对象 public PathView(Context context, AttributeSet attrs) {
super(context, attrs); /*
* 实例化画笔并设置属性
*/
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.CYAN);
mPaint.setStrokeWidth(5); mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG | Paint.LINEAR_TEXT_FLAG);
mTextPaint.setColor(Color.DKGRAY);
mTextPaint.setTextSize(20); // 实例化路径
mPath = new Path(); // 添加一条弧线到Path中
RectF oval = new RectF(100, 100, 300, 400);
mPath.addOval(oval, Path.Direction.CW);
} @Override
protected void onDraw(Canvas canvas) {
// 绘制路径
canvas.drawPath(mPath, mPaint); // 绘制路径上的文字
canvas.drawTextOnPath("ad撒发射点发放士大夫斯蒂芬斯蒂芬森啊打扫打扫打扫达发达省份撒旦发射的", mPath, 0, 0, mTextPaint);
}
}

我们往Path中添加了一条闭合方向为CW椭圆形的闭合曲线并将其绘制在Canvas上,同时呢我们沿着该曲线绘制了一段文本,效果如下:

转载爱哥自定义View系列--Canvas详解

如果我们把闭合方向改为CCW那么会发生什么呢?

mPath.addOval(oval, Path.Direction.CCW);

转载爱哥自定义View系列--Canvas详解

沿着Path的文字全都在闭合曲线的“内部”了,Path.Direction闭合方向大概就是这么个意思。对于我们平时开发来说,掌握Path的以上一些方法已经是足够了,当然Path的方法还有很多,但是因为平时开发涉及的少,我也就不累赘了,毕竟用得少或者根本不会用到的东西说了也是浪费口水,对吧。

简单地介绍了Path之后回到我们的Canvas中,关于裁剪的方法

clipPath(Path path)

是不是变得透彻起来呢?

我们可以利用该方法从Canvas中“挖”取一块不规则的画布:

public class CanvasView extends View {
private Path mPath; public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs); mPath = new Path();
mPath.moveTo(50, 50);
mPath.lineTo(75, 23);
mPath.lineTo(150, 100);
mPath.lineTo(80, 110);
mPath.close();
} @Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.BLUE); canvas.clipPath(mPath); canvas.drawColor(Color.RED);
}
}

转载爱哥自定义View系列--Canvas详解

回顾Canvas中有关裁剪的方法,你会发现有一大堆带有Region.Op参数的重载方法:

clipPath(Path path, Region.Op op)
clipRect(Rect rect, Region.Op op)
clipRect(RectF rect, Region.Op op)
clipRect(float left, float top, float right, float bottom, Region.Op op)
clipRegion(Region region, Region.Op op)

要明白这些方法的Region.Op参数那么首先要了解Region为何物。Region的意思是“区域”,在Android里呢它同样表示的是一块封闭的区域,Region中的方法都非常的简单,我们重点来瞧瞧Region.Op,Op是Region的一个枚举类,里面呢有六个枚举常量:

转载爱哥自定义View系列--Canvas详解

那么Region.Op究竟有什么用呢?其实它就是个组合模式,在1/6中我们曾学过一个叫图形混合模式的,而在本节开头我们也曾讲过Rect也有类似的组合方法,Region.Op灰常简单,如果你看过1/6的图形混合模式的话。这里我就给出一段测试代码,大家可以尝试去改变不同的组合模式看看效果

public class CanvasView extends View {
private Region mRegionA, mRegionB;// 区域A和区域B对象
private Paint mPaint;// 绘制边框的Paint public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs); // 实例化画笔并设置属性
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.WHITE);
mPaint.setStrokeWidth(2); // 实例化区域A和区域B
mRegionA = new Region(100, 100, 300, 300);
mRegionB = new Region(200, 200, 400, 400);
} @Override
protected void onDraw(Canvas canvas) {
// 填充颜色
canvas.drawColor(Color.BLUE); canvas.save(); // 裁剪区域A
canvas.clipRegion(mRegionA); // 再通过组合方式裁剪区域B
canvas.clipRegion(mRegionB, Region.Op.DIFFERENCE); // 填充颜色
canvas.drawColor(Color.RED); canvas.restore(); // 绘制框框帮助我们观察
canvas.drawRect(100, 100, 300, 300, mPaint);
canvas.drawRect(200, 200, 400, 400, mPaint);
}
}

以下是各种组合模式的效果

DIFFERENCE

转载爱哥自定义View系列--Canvas详解

最终区域为第一个区域与第二个区域不同的区域。

INTERSECT

转载爱哥自定义View系列--Canvas详解

最终区域为第一个区域与第二个区域相交的区域。

REPLACE

转载爱哥自定义View系列--Canvas详解

最终区域为第二个区域。

REVERSE_DIFFERENCE

转载爱哥自定义View系列--Canvas详解

最终区域为第二个区域与第一个区域不同的区域。

UNION

转载爱哥自定义View系列--Canvas详解

最终区域为第一个区域加第二个区域。

XOR

转载爱哥自定义View系列--Canvas详解

最终区域为第一个区域加第二个区域并减去两者相交的区域。

Region.Op就是这样,它和我们之前讲到的图形混合模式几乎一模一样换汤不换药……我在做示例的时候仅仅是使用了一个Region,实际上Rect、Cricle、Ovel等封闭的曲线都可以使用Region.Op,介于篇幅,而且也不难以理解就不多说了。

有些童鞋会问那么Region和Rect有什么区别呢?首先最重要的一点,Region表示的是一个区域,而Rect表示的是一个矩形,这是最根本的区别之一,其次,Region有个很特别的地方是它不受Canvas的变换影响,Canvas的local不会直接影响到Region自身,什么意思呢?我们来看一个simple你就会明白:

public class CanvasView extends View {
private Region mRegion;// 区域对象
private Rect mRect;// 矩形对象
private Paint mPaint;// 绘制边框的Paint public CanvasView(Context context, AttributeSet attrs) {
super(context, attrs); // 实例化画笔并设置属性
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.DKGRAY);
mPaint.setStrokeWidth(2); // 实例化矩形对象
mRect = new Rect(0, 0, 200, 200); // 实例化区域对象
mRegion = new Region(200, 200, 400, 400);
} @Override
protected void onDraw(Canvas canvas) {
canvas.save(); // 裁剪矩形
canvas.clipRect(mRect);
canvas.drawColor(Color.RED); canvas.restore(); canvas.save(); // 裁剪区域
canvas.clipRegion(mRegion);
canvas.drawColor(Color.RED); canvas.restore(); // 为画布绘制一个边框便于观察
canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mPaint);
}
}

大家看到,我在[0, 0, 200, 200]和[200, 200, 400, 400]的位置分别绘制了Rect和Region,它们两个所占大小是一样的:

转载爱哥自定义View系列--Canvas详解

画布因为和屏幕一样大,so~~我们看不出描边的效果,这时,我们将Canvas缩放至75%大小,看看会发生什么:

@Override
protected void onDraw(Canvas canvas) {
// 缩放画布
canvas.scale(0.75F, 0.75F); canvas.save(); // 裁剪矩形
canvas.clipRect(mRect);
canvas.drawColor(Color.RED); canvas.restore(); canvas.save(); // 裁剪区域
canvas.clipRegion(mRegion);
canvas.drawColor(Color.RED); canvas.restore(); // 为画布绘制一个边框便于观察
canvas.drawRect(0, 0, canvas.getWidth(), canvas.getHeight(), mPaint);
}

这时我们会看到,Rect随着Canvas的缩放一起缩放了,但是Region依旧泰山不动地淡定:

转载爱哥自定义View系列--Canvas详解

我们先来了解一个关于“层”的设计理念,为什么说它是一个设计理念呢?因为在很多很多的地方,当然不止是开发,还有设计等领域你都能见到它的踪影,那么何为图层呢?大家小时候一定都画过画,比如下面这种:

转载爱哥自定义View系列--Canvas详解

一个松鼠、几棵树、两个鸟、一个日,这几个简单的图画其实就包含了最简单“层”的概念,由图我们可以知道松鼠一定是在树和地面的前面,而树和地面的关系呢则比较模糊,可以是树在前也可以是地面在前,日肯定是在最底层的,两只鸟我们按照一般逻辑可以推测在日的上一层也就是倒数第二层,那么从底层到顶层我们就有这样的一个层次关系:日-鸟-树/地面-地面/树-松鼠,这么一说大家觉得好像也是,但是目测没毛用啊……意义何在,别急,想像一下,这时候如果你不想要松鼠而是想放一只猫在前面……或者你想把松鼠放在树的后面“藏”起来……这时你就蛋疼了,不停地拿橡皮擦擦啊擦草啊草,一不小心还得把其他的擦掉一块,这时候你就会想可以不可以有这么一个功能能让不同的元素通过一定的次序单独地画在一张大小一致“纸”上直到画完最后一个元素后把这些所有“纸”上的元素都整合起来构成一幅完整的图画呢?这样一个功能的存在能大大提高我们绘图的效率还能实现更多的绘图功能,基于这样的一个假想,“层”的概念应运而生:

转载爱哥自定义View系列--Canvas详解

如上图所示,位于最底层的是一个圆,第二层是一个蓝色的椭圆,最顶层的是两个蓝色的圆,三个层中不同的元素最终构成右边的图像,这就是图层最直观也是最简单的体现。在Android中我们可以使用Canvas的saveXXX和restoreXXX方法来模拟图层的类似效果:

public class LayerView extends View {
private Paint mPaint;// 画笔对象 private int mViewWidth, mViewHeight;// 控件宽高 public LayerView(Context context, AttributeSet attrs) {
super(context, attrs); // 实例化画笔对象并设置其标识值
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
} @Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
/*
* 获取控件宽高
*/
mViewWidth = w;
mViewHeight = h;
} @Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.save();
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}
}

如代码所示,我们先在onDraw方法中绘制一个红色的大矩形再保存画布绘制了一个蓝色的小矩形:

转载爱哥自定义View系列--Canvas详解

此时我们尝试旋转一下我们的画布:

@Override
protected void onDraw(Canvas canvas) {
// 旋转画布
canvas.rotate(30); /*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.save();
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}

如代码所示顺时针旋转30度,这里要注意,我们在对Canvas(实际上大多数Android中的其他与坐标有关的)进行坐标操作的时候,默认情况下是以控件的左上角为原点坐标的,效果如下:

转载爱哥自定义View系列--Canvas详解

可以看到两个矩形都一起飞了,可是我们只想让蓝色的飞而红色的不动怎么办呢?很简单,我们只在保存的图层里操作即可:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.save();
mPaint.setColor(Color.BLUE); // 旋转画布
canvas.rotate(30);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}

可以看到,我们只针对蓝色的矩形进行了旋转:

转载爱哥自定义View系列--Canvas详解

至此结合上一节对Canvas的一些原理阐述我们该对它有个全新的认识,之前我们一直称其为画布,其实更准确地说Canvas是一个容器,如果把Canvas理解成画板,那么我们的“层”就像张张夹在画板上的透明的纸,而这些纸对应到Android则是一个个封装在Canvas中的Bitmap。

除了save()方法Canvas还给我们提供了一系列的saveLayerXXX方法给我们保存画布,与save()方法不同的是,saveLayerXXX方法会将所有的操作存到一个新的Bitmap中而不影响当前Canvas的Bitmap,而save()方法则是在当前的Bitmap中进行操作,并且只能针对Bitmap的形变和裁剪进行操作,saveLayerXXX方法则无所不能,当然两者还有很多的不同,我们稍作讲解。虽然save和saveLayerXXX方法有着很大的区别但是在一般应用上两者能实现的功能是差不多,上面的代码我们也可以改成这样:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.saveLayer(0, 0, mViewWidth, mViewHeight, null, Canvas.ALL_SAVE_FLAG);
mPaint.setColor(Color.BLUE); // 旋转画布
canvas.rotate(30);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}

当然实现的效果也是一样的就不多说了。saveLayer可以让我们自行设定需要保存的区域,比如我们可以只保存和蓝色方块一样的区域:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.saveLayer(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, null, Canvas.ALL_SAVE_FLAG);
mPaint.setColor(Color.BLUE); // 旋转画布
canvas.rotate(30);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}

这时候如果你运行就会发现蓝色的方块已经不见了,因为我们图层的大小就这么点,超出的部分就不能被显示了,这时我们改小画布旋转:

canvas.rotate(5);

你就可以看到旋转后的蓝色方块的一角:

转载爱哥自定义View系列--Canvas详解

是不是有点类似于clipRect的效果呢?那么很多朋友会好奇为什么会有这样一种保存一小块画布区域的功能呢?其实原因很简单,上面我们说了saveLayerXXX方法会将操作保存到一个新的Bitmap中,而这个Bitmap的大小取决于我们传入的参数大小,Bitmap是个相当危险的对象,很多朋友在操作Bitmap时不太理解其原理经常导致OOM,在saveLayer时我们会依据传入的参数获取一个相同大小的Bitmap,虽然这个Bitmap是空的但是其会占用一定的内存空间,我们希望尽可能小地保存该保存的区域,而saveLayer则提供了这样的功能,顺带提一下,onDraw方法传入的Canvas对象的Bitmap在Android没引入HW之前理论上是无限大的,实际上其依然是根据你的图像来不断计算的,而在引入HW之后,该Bitmap受到限制,具体多大大家可以尝试画一个超长的path运行下你就可以在Logcat中看到warning。

好了,闲话不扯,接着说,除了saveLayer,Canvas还提供了一个saveLayerAlpha方法,顾名思义,该方法可以在我们保存画布时设置画布的透明度:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存画布并绘制一个蓝色的矩形
*/
canvas.saveLayerAlpha(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, 0x55, Canvas.ALL_SAVE_FLAG);
mPaint.setColor(Color.BLUE); // 旋转画布
canvas.rotate(5);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore();
}

我们将saveLayer替换成saveLayerAlpha并设置透明值为0x55,运行可得如下效果:

转载爱哥自定义View系列--Canvas详解

可见蓝色的方块被半透明了。such easy!如果大家留心,会发现save()也有个重载方法save (int saveFlags),而saveLayer和saveLayerAlpha你也会发现又一个类似的参数,那么这个参数是干嘛用的呢?在Canvas中有六个常量值:

转载爱哥自定义View系列--Canvas详解

这六个常量值分别标识了我们在调用restore方法后还原什么,六个标识位除了CLIP_SAVE_FLAG、MATRIX_SAVE_FLAG和ALL_SAVE_FLAG是save和saveLayerXXX方法都通用外其余三个只能使saveLayerXXX方法有效,ALL_SAVE_FLAG很简单也是我们新手级常用的标识保存所有,CLIP_SAVE_FLAG和MATRIX_SAVE_FLAG也很好理解,一个是裁剪的标识位一个是变换的标识位,CLIP_TO_LAYER_SAVE_FLAG、FULL_COLOR_LAYER_SAVE_FLAG和HAS_ALPHA_LAYER_SAVE_FLAG只对saveLayer和saveLayerAlpha有效,CLIP_TO_LAYER_SAVE_FLAG表示对当前图层执行裁剪操作需要对齐图层边界,FULL_COLOR_LAYER_SAVE_FLAG表示当前图层的色彩模式至少需要是8位色,而HAS_ALPHA_LAYER_SAVE_FLAG表示在当前图层中将需要使用逐像素Alpha混合模式,关于色彩深度和Alpha混合大家可以参考*,这里就不多说,这些标识位,特别是layer的标识位,大大超出了本系列的范畴,我就不多说了,平时使用大家可以直接ALL_SAVE_FLAG,有机会将单独开一篇剖析Android对色彩的处理。

所有的save、saveLayer和saveLayerAlpha方法都有一个int型的返回值,该返回值作为一个标识给与了一个你当前保存操作的唯一ID编号,我们可以利用restoreToCount(int saveCount)方法来指定在还原的时候还原哪一个保存操作:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存并裁剪画布填充绿色
*/
int saveID1 = 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 saveID2 = 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.restoreToCount(saveID1);
}

如上代码所示,我们第一次保存画布并获取其返回值:

int saveID1 = canvas.save(Canvas.CLIP_SAVE_FLAG);

然后对画布进行裁剪并填色,第二次保存画布并获取其返回值:

int saveID2 = canvas.save(Canvas.MATRIX_SAVE_FLAG);

然后绘制一个蓝色的矩形,最后我们只还原了了saveID1的画布状态,运行一下你会发现好像效果没什么不同啊:

转载爱哥自定义View系列--Canvas详解

然后我们试试

canvas.restoreToCount(saveID2);

发现效果还是一样…………很多童鞋就困惑了,是哪不对么?没有,其实都是对的,你觉得奇怪是你还不理解save和restore,这里我在restore之后再绘制一个矩形:

@Override
protected void onDraw(Canvas canvas) {
/*
* 绘制一个红色矩形
*/
mPaint.setColor(Color.RED);
canvas.drawRect(mViewWidth / 2F - 200, mViewHeight / 2F - 200, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint); /*
* 保存并裁剪画布填充绿色
*/
int saveID1 = 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 saveID2 = 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.restoreToCount(saveID2); mPaint.setColor(Color.YELLOW);
canvas.drawRect(mViewWidth / 2F - 400, mViewHeight / 2F - 400, mViewWidth / 2F + 400, mViewHeight / 2F + 400, mPaint);
}

可以看到我在

canvas.restoreToCount(saveID2);

之后又绘制了一个黄色的矩形:

转载爱哥自定义View系列--Canvas详解

可是不管你如何调大这个矩形,你会发现它就那么大点……也就是说,这个黄色的矩形其实是被clip掉了,进一步说,我们绘制黄色矩形的这个操作其实说白了就是在saveID1的状态下进行的。前面我们曾说过save和saveLayerXXX方法有着本质的区别,saveLayerXXX方法会将所有操作在一个新的Bitmap中进行,而save则是依靠stack栈来进行,假设我们有如下代码:

@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);
}

此时,在Canvas内部会有这样的一个Stack栈:

转载爱哥自定义View系列--Canvas详解

Canvas会默认保存一个底层的空间给我们绘制一些东西,当我们没有调用save方法时所有的绘图操作都在这个Default Stack ID中进行,每当我们调用一次save就会往Stack中存入一个ID,将其后所有的操作都在这个ID所指向的空间进行直到我们调用restore方法还原操作,上面代码我们save了三次且没有restore,stack的结构就如上图所示,此时如果我们继续绘制东西,比如:

@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); mPaint.setColor(Color.CYAN);
canvas.drawRect(mViewWidth / 2F, mViewHeight / 2F, mViewWidth / 2F + 200, mViewHeight / 2F + 200, mPaint);
}

我们在saveID3之后又画了一个青色的矩形,只要你不是*明眼都能看出这段代码是在saveID3所标识的空间中绘制的,因此其必然会受到saveID3的约束旋转:

转载爱哥自定义View系列--Canvas详解

除此之外,大家还可以很明显的看到,这个矩形除了被旋转,还被clip了~也就是说saveID1、saveID2也同时对其产生了影响,此时我们再次尝试在saveID2绘制完我们想要的东西后将其还原:

/*
* 保存并裁剪画布填充绿色
*/
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();

同时将青色的矩形变大一点:

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

这时我们得到什么样的效果呢:

转载爱哥自定义View系列--Canvas详解

其实猜都猜得到,saveID2已经不再对下面的saveID3起作用了,也就是说当我们调用canvas.restore()后标志着上一个save操作的结束或者说回滚了。同理,我们再把saveID1也restore:

/*
* 保存并裁剪画布填充绿色
*/
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将彻底不再受前面操作的影响:

转载爱哥自定义View系列--Canvas详解

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

/*
* 保存画布并旋转后绘制一个蓝色的矩形
*/
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状态的影响:

转载爱哥自定义View系列--Canvas详解

上面我们提到的restoreToCount(int saveCount)方法接受一个标识值,我们可以根据这个标识值来还原特定的栈空间,效果类似就不多说了。每当我们调用restore还原Canvas,对应的save栈空间就会从Stack中弹出去,Canvas提供了getSaveCount()方法来为我们提供查询当前栈中有多少save的空间:

@Override
protected void onDraw(Canvas canvas) {
System.out.println(canvas.getSaveCount());
/*
* 保存并裁剪画布填充绿色
*/
int saveID1 = canvas.save(Canvas.CLIP_SAVE_FLAG);
System.out.println(canvas.getSaveCount());
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);
System.out.println(canvas.getSaveCount());
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);
System.out.println(canvas.getSaveCount()); // 旋转画布
canvas.rotate(5);
mPaint.setColor(Color.BLUE);
canvas.drawRect(mViewWidth / 2F - 100, mViewHeight / 2F - 100, mViewWidth / 2F + 100, mViewHeight / 2F + 100, mPaint);
canvas.restore(); System.out.println(canvas.getSaveCount());
mPaint.setColor(Color.CYAN);
canvas.drawRect(mViewWidth / 2F, mViewHeight / 2F, mViewWidth / 2F + 400, mViewHeight / 2F + 400, mPaint);
}

运行后你会看到Logcat的如下输出:

转载爱哥自定义View系列--Canvas详解

OK,对层的了解到此为止,接下来我们主要来看看Canvas中的变换操作,说起变换,无非就几种:平移、旋转、缩放和错切,而我们的Canvas也继承了变换的精髓,同样提供了这几种相应的方法,前面的很多章节我们也都用到了,像translate(float dx, float dy)方法平移画布用了无数次,这里再次强调,translate方法会改变画布的原点坐标,原点坐标对变换的影响弥足轻重,前面也多次强调了!scale(float sx, float sy)缩放也很好理解,但是它有一个重载方法scale(float sx, float sy, float px, float py),后两个参数用于指定缩放的中心点,前两个参数用于指定横纵向的缩放比率值在0-1之间为缩小:

public class LayerView extends View {
private Bitmap mBitmap;// 位图对象 private int mViewWidth, mViewHeight;// 控件宽高 public LayerView(Context context, AttributeSet attrs) {
super(context, attrs); // 从资源中获取位图对象
mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.z);
} @Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
/*
* 获取控件宽高
*/
mViewWidth = w;
mViewHeight = h; // 缩放位图与控件一致
mBitmap = Bitmap.createScaledBitmap(mBitmap, mViewWidth, mViewHeight, true);
} @Override
protected void onDraw(Canvas canvas) {
canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.scale(1.0F, 1.0F);
canvas.drawBitmap(mBitmap, 0, 0, null);
canvas.restore();
}
}

当缩放比率为1时表示不缩放:

转载爱哥自定义View系列--Canvas详解

我们改变下缩放比率:

canvas.scale(0.8F, 0.35F);

此时画面效果如下:

转载爱哥自定义View系列--Canvas详解

可以看到缩放中心在左上角,我们可以使用scale的重载方法更改缩放中心:

canvas.scale(0.8F, 0.35F, mViewWidth, 0);

效果如下,很好理解:

转载爱哥自定义View系列--Canvas详解

rotate(float degrees)和重载方法rotate(float degrees, float px, float py)类似前面也用过不少就不多说了,没接触过的只有skew(float sx, float sy)错切方法,关于错切的概念前面我们都有讲过很多,其实知道原理,方法再怎么变都不难:

@Override
protected void onDraw(Canvas canvas) {
canvas.save(Canvas.MATRIX_SAVE_FLAG);
canvas.skew(0.5F, 0F);
canvas.drawBitmap(mBitmap, 0, 0, null);
canvas.restore();
}

两个参数与scale类似表示横纵向的错切比率,上面代码的效果如下:

转载爱哥自定义View系列--Canvas详解

在之前的章节中我们曾讲过一个类似的用来专门操作变换的玩意Matrix,之前我也说过我们会在很多地方用到这畜生,Canvas也提供了对应的方法来便于我们设置Matrix直接变换Canvas:

@Override
protected void onDraw(Canvas canvas) {
canvas.save(Canvas.MATRIX_SAVE_FLAG);
Matrix matrix = new Matrix();
matrix.setScale(0.8F, 0.35F);
matrix.postTranslate(100, 100);
canvas.setMatrix(matrix);
canvas.drawBitmap(mBitmap, 0, 0, null);
canvas.restore();
}

运行效果如下:

转载爱哥自定义View系列--Canvas详解

 
 
 
 
 
 
 
 
 
 
 
 
 
 
上一篇:2. Unix标准


下一篇:记录一下;java程序调用shell脚本由于编码格式问题导致命令行command not found