Android View | Canvas详解

前言

熟悉Android自定义View的开发者对于Canvas应该比较熟悉,在我们使用自定义View时的第三步即绘制,会重写下面方法:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
}

我们使用Canvas实例的各种API可以实现我们想要的结果,但是一直没有做个系统的梳理,本篇文章就来好好梳理一下。

正文

首先我们要明确一件事,就是这个Canvas,直接翻译是画布的意思,不过这里我们把它理解为一种绘制的规则,用来规定绘制的内容。

所以内容实际上是绘制在屏幕上的,Canvas只是指定了绘制内容的规则,内容的位置由坐标决定,坐标是由画布而言的。

Canvas对象创建

既然Canvas是绘制规则,那这个Canvas是如何来的呢 下面有4种方式能获取到Canvas实例。

  1. 通过空构造方法。

代码如下:

val canvas = Canvas()

这里虽然可以通过空构造函数来获取一个Canvas实例,但是Canvas绘制完的内容需要一个容器给保存下来,而这个容器就是Bitmap,可以设置一个Bitmap对象:

canvas.setBitmap(bitmap)
  1. 通过带Bitmap的构造函数。

这样的话,Bitmap会保存Canvas所绘制的信息,代码如下:

val canvas = Canvas(bitmap)
  1. 重写View.onDraw

该方式也是我们使用最多的方式,我们在使用自定义View时就是通过重写onDraw方法来实现的。这里我们可以想一下,该Canvas绘制的内容是显示在哪的,当一个View经过测量和布局后,它可以看成是一个空白的矩形,这时该Canvas就是这个View所对应的Canvas对象:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
}

这里又有许多细节,比如这个View所对应的Canvas是如何来的,又是如何和这个View是一一对应的,这部分内容,我们后面文章再探究分析。

  1. 通过SurfaceView

该方法在我们使用SurfaceView时可以使用,代码如下:

val surfaceView = SurfaceView(context)
//从surfaceView的surfaceHolder里锁定获取Canvas
val canvas = surfaceView.holder.lockCanvas()
//进行canvas操作
...
//Canvas操作结束后解锁并执行Canvas
surfaceView.holder.unlockCanvasAndPost(canvas)

关于Android的Surface机制,后面文章再探究分析。

API梳理

本篇文章主要介绍Canvas的使用细节,我们现在就来看看Canvas可以为我们做哪些事情。记住,Canvas即是画布,也是绘制规则。

绘制颜色

相关API是drawColor,可以在整个绘制区域统一涂上指定的颜色,因为它没有指定范围,所以范围就是Canvas所绘制的范围。

一般用来绘制背景,或者绘制一个遮盖:

canvas?.drawColor(context.getColor(R.color.blue))

全部区域背景:

image.png

canvas?.drawColor(context.getColor(R.color.blue))

val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.pic)
canvas?.drawBitmap(bitmap, 0f, 0f, paint1)

在背景上面绘制一张Bitmap图片,这里关于绘制图片的API后面再介绍:

image.png

可以发现默认是从绘制区域左上角开始绘制,那如何限制图片绘制的区域呢,这个我们后面再说。

再在图片上加一个半透明遮罩:

canvas?.drawColor(context.getColor(R.color.blue))

val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.pic)
canvas?.drawBitmap(bitmap, 0f, 0f, paint1)
canvas?.drawColor(Color.parseColor("#88880000"))

加完半透明红色遮罩:

image.png

绘制颜色API非常简单,但是我们从效果可以得到如下知识点:

  • 默认情况下,不限制区域,绘制就是从Canvas所对应的区域左上角开始。

  • 绘制内容都是一层层遮盖,就像是画画在画板上一样,下层的样式会被遮盖。

绘制基本形状

绘制基本形状,比如点、线、矩形、圆、椭圆等等,但是这时需要一个辅助类叫做Paint,即画笔。前面的drawColor可以想象成我们在一个区域上盖上一层透明或者半透明的布,而这时需要一只笔来画一些具体的东西。

比如要绘制一条直线,就可以设置该直线的宽度和样色等信息,所以我们先来看看Paint类的介绍。

Paint类

官方该类的注释如下:

The Paint class holds the style and color information about how to draw geometries, text and bitmaps.

Paint类包含有关绘制几何图形、文本和位图的样式和颜色信息。所以我们在绘制基本图形时就需要用到Paint,使用如下:

private var paint: Paint = Paint().apply {
    //设置画笔的颜色
    color = context.resources.getColor(R.color.blue)
    //设置画笔模式
    style = Paint.Style.FILL
    //设置画笔粗细
    strokeWidth = 10F
    //设置字体大小
    textSize = 15F
    //设置文字对齐方式
    textAlign = Paint.Align.LEFT
    //设置文本的下划线
    isUnderlineText = true
    //设置文本的删除线
    isStrikeThruText = true
    //设置文本粗体
    isFakeBoldText = true
    //设置斜体
    textSkewX = -0.5F
    //设置文字阴影
    setShadowLayer(5F,5F,5F,Color.BLUE)
}

上面列举了Paint的一些常用设置,其中不同设置的效果比如模式,在后面会给出具体效果区别,还有一些不常用比较复杂的设置,比如设置Shader,这个等后面文章再仔细探究。

绘制点

好了,对Paint有了基础了解后,我们就来看看如何绘制点。

绘制点的API比较简单如下:

canvas?.drawPoint(100F,100F,paint)

其中前俩个参数就是坐标,点的大小可以通过设置paint的storkeWidth即画笔粗细来控制,比如设置粗细为40,效果如下:

image.png

你或许觉得这个点有点大,是不是可以通过绘制矩形的方式来绘制,当然可以,后面再说。

也说了,这里的点是一个矩形,即方形的点,那要绘制一个圆形的点呢,通过下面代码:

//设置画笔粗细
strokeWidth = 40F
//设置圆形的点
strokeCap = Paint.Cap.ROUND

这里设置ROUND就是圆形点,设置为SQUARE或者BUTT就是方形点,效果如下:

image.png

其实Paint的storkeCap属性并不是专门用来设置点的形状的,而是一个设置线段终点形状的方法,一张图表示如下:

image.png

绘制直线

绘制直线比较简单,直接调用drawLine方法即可:

canvas?.drawLine(100F,100F,200F,300F,paint)

效果如下图:

image.png

绘制矩形

矩形的对角线定点确定一个矩形,而可以直接采用左上角和右下角这俩个坐标即可。

关于绘制矩形,Canvas提供了3种重载方法:

  1. 直接传入俩个定点的坐标。
canvas?.drawRect(50F, 50F, 300F, 300F, paint)
  1. 将俩个点封装成Rect,再通过绘制Rect,Rect就是表示4个定点的矩形范围。
val rect = Rect(50, 50, 300, 300)
canvas?.drawRect(rect,paint)
  1. 将俩个点封装成RectF,这个Rect的唯一区别就是精度不一样,一个是Int,一个是Float。
val rect = RectF(50F, 50F, 300F, 300F)
canvas?.drawRect(rect,paint)

上面3种方式绘制矩形是一样的,如下:

image.png

这里我们需要把画笔的宽度调小一点,然后我们来分析一下画笔的模式,一共有3种:

  1. FILL,表示填充模式,这种模式对于画基础图形比如圆形、矩形等都是有区别的,默认就是FILL,比如上面矩形我们调小画笔宽度,设置为FILL,效果如下:

image.png

  1. STROKE,表示画线模式,或者叫做勾边模式,效果如下:

image.png

  1. FILL_AND_STROKE,表示即填充又勾边,俩种一起用,单纯绘制矩形时,和上面是一样的效果。

绘制圆角矩形

和绘制矩形类似,只不过比绘制矩形多了2个参数,来表示圆角,代码如下:

val rect = RectF(50F, 50F, 300F, 300F)
canvas?.drawRoundRect(rect,20F,20F,paint)

效果如下:

image.png

圆角矩形的角是椭圆的圆弧,如下图:

image.png

所以这里的rx恰好是长度的一半,ry是高度的一半,将绘制出一个椭圆:

//rx和ry是矩形长、宽的一半
val rect = RectF(100F, 100F, 300F, 200F)
canvas?.drawRoundRect(rect,100F,50F,paint)

效果如下:

image.png

绘制椭圆

其实上面绘制圆角矩形就说出了绘制椭圆的原理,由于椭圆的表达式比较复杂,所以这里采用简单的方法,其实椭圆就是矩形的内切圆。

比如下面代码绘制椭圆的同时,又绘制了一个矩形:

val rect = RectF(100F, 100F, 300F, 200F)
canvas?.drawOval(rect, paint)
canvas?.drawRect(rect, paint)

这里注意paint的模式必须设置为STROKE才有下面的效果:

image.png

绘制圆

由于圆比较好表达,使用圆心和半径就可以决定一个圆了,代码如下:

canvas?.drawCircle(200F, 200F, 100F, paint)

效果如图:

image.png

绘制圆弧or扇形

通过我们前面知道,绘制圆和椭圆其实就是矩形的内切圆,所以绘制圆弧也就比绘制椭圆(圆是特殊的椭圆)多几个参数:

  1. startAngle:角度的起始位置,其中设置为0F时,表示X轴向右方向。
  2. sweepAngle:角度扫过的角度。
  3. useCenter:是否使用中心,即绘制的圆弧或扇形是否经过原点。

测试代码如下:

val rect = RectF(100F, 100F, 400F, 300F)
canvas?.drawRect(rect, paint)
canvas?.drawArc(rect, 0F, 90F, true, paint2)

效果如下:

image.png

当设置不进过原点时:

image.png

可以发现不仅过原点时,绘制的区域就是起始点、终点和圆弧组成的区域。

绘制文字

绘制文字涉及的细节知识点更多一点,但是本篇文章主要是介绍Canvas的API,所以绘制文字的更细节点后面再说。绘制文字一般分为3种API,下面分别介绍。

  1. 指定文本的开始的位置。

即绘制一个文本时,可以设置其开始的坐标,即设置文本基线的位置。这里有个概念叫做基线,默认情况下,绩效的X坐标轴在字符串的左侧,绩效的Y坐标轴在字符串下方,所以测试代码如下:

canvas?.drawText("abcdefg",100F,100F,paint)

在(100,100)位置开始绘制字符串:

image.png

同时对于字符串,可以选择其开始和结束的下标,来绘制文本的一部分:

canvas?.drawText("abcdefg", 1, 3, 100F, 100F, paint)

image.png

可以发现这里的开始和结束坐标是"顾头不顾尾", 即[startIndex,endIndex),包含开始坐标,不包含结束坐标。

而对于字符数组时,可以指定其开始坐标,以及需要绘制的个数count:

canvas?.drawText(charArrayOf('a', 'b', 'c', 'd', 'e', 'f', 'g'), 1, 3, 100F, 100F, paint)

image.png

上面有的API是开始结束坐标,有的是开始坐标加个数,要注意区分。

  1. 分别指定文本的位置。

通过使用drawPosText来指定每个字符的坐标:

canvas?.drawPosText("ABC", floatArrayOf(100F, 100F, 200F, 200F, 300F, 300F), paint)

效果如下:

image.png

  1. 根据路径绘制文字。

这里涉及到Path类的使用,后面细说,简单来说就是指定一条path,然后在该path上绘制文字,前面我们所有绘制的文字都是按照X/Y坐标轴基线来绘制的,测试代码如下:

//创建path对象
val path = Path()
//设置path轨迹
path.cubicTo(100F, 300F, 200F, 100F, 300F, 300F)
//绘制path
canvas?.drawPath(path, paint)
//在path上绘制文字
canvas?.drawTextOnPath("在path绘制文本", path, 50F, 0F, paint2)

上面关于path的方法暂时不研究,只需要知道先绘制了一个路径,然后可以在该路径上绘制文本,效果如下:

image.png

绘制图片

绘制图片,这里可以分为俩类:绘制矢量图(drawPicture)和绘制位图(drawBitmap)。

drawPicture

绘制矢量图的内容,即绘制存储在矢量图里某个时刻Canvas绘制内容的操作。

这里就涉及到一个类叫做Picture,它的作用是存储某个时刻Canvas绘制内容的操作,然后在使用时就使用这个Picture即可,它相比于再次调用各种绘图API,会节省操作和时间。

具体使用也非常容易,测试代码如下:

//先创建一个Picture对象
val mPicture = Picture()
//开始录制
val recordingCanvas = mPicture.beginRecording(500, 500)
//绘制内容和操作canvas
recordingCanvas.translate(200F, 200F)
recordingCanvas.drawCircle(100F, 100F, 50F, paint)
//结束录制
mPicture.endRecording()

//将存在在Picture中内容绘制出来
canvas?.drawPicture(
    mPicture,
    RectF(0F, 0F, mPicture.width.toFloat(), mPicture.height.toFloat())
)

上述代码效果如下:

image.png

所以对于比较复杂的绘制操作,使用Picture可以减少绘制时间。

drawBitmap

绘制位图,这个可以说是非常常用的API,这里唯一要注意的就是几个方法的重载,以及分别表示什么意思。

首先就是最常见的方法:

public void drawBitmap(@NonNull Bitmap bitmap, @NonNull Matrix matrix, @Nullable Paint paint)

测试代码如下:

//获取bitmap
val bitmap = BitmapFactory.decodeResource(resources,R.mipmap.pic)
//绘制Bitmap
canvas?.drawBitmap(bitmap,Matrix(),paint)

效果如下:

image.png

其中Matrix类可以对图片进行操作和处理,等后面细说。

然后该API还可以设置绘制Bitmap的坐标:

//获取bitmap
val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.pic)
//绘制Bitmap
canvas?.drawBitmap(bitmap, 300F, 400F, paint)

效果如下:

image.png

除了上面的绘制Bitmap方法外,下面这个非常常见:

public void drawBitmap(@NonNull Bitmap bitmap, @Nullable Rect src, @NonNull Rect dst,
        @Nullable Paint paint)

这里有2个Rect参数,其中src表示需要被绘制Bitmap的区域,即从Bitmap上取出需要绘制的区域;而dst则表示显示的区域,如果src规定的绘制区域大于dst的区域,图片大小会被缩放。

测试代码如下:

//获取bitmap
val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.pic)
//指定需要绘制图片的区域
val src = Rect(0,0,bitmap.width / 2,bitmap.height / 2)
//指定绘制的区域
val dst = Rect(200,200,500,500)
//绘制Bitmap
canvas?.drawBitmap(bitmap,src,dst,paint)

效果如下:

image.png

会发现这里Bitmap只绘制了其一部分,而且还被拉伸了。

绘制路径

绘制路径比较复杂点,涉及到Path类的使用。Path的定义就是路径,即无数个点连起来的线,作用就是用于描述路径,可以直接描述直线、二次曲线、三次曲线等。Path有俩类方法,一类是直接描述路径,一类是复制的设置或计算。

直接描述路径

而直接描述路径又可以细分为俩组,分别是添加子图形和画线。

添加子图形

这一类方法和前面绘制基本形状的功能是一样的,API的样式是addXXX()这种样式,可以添加圆、椭圆、矩形和圆角矩形。

比如下面测试代码:

path.addCircle(200F, 200F, 100F, Path.Direction.CW)
path.addCircle(280F,200F,100F,Path.Direction.CW)
canvas?.drawPath(path, paint)

上面使用path绘制了2个圆,效果如下图:

image.png

这里我们可以发现addCircle的前3个参数就是决定圆的属性和位置,但是最后一个Path.Direction是什么意思呢

这个dir参数表示画圆的路径方向,路径方向有2种:顺时针(CW)和逆时针(CCW),对于普通情况CW和CCW是没有区别的,只有在需要填充图形(即Paint的Style为FILL或者FILL_AND_STROKE),并且图形出现相交时,用于判断填充范围时,这个属性才有用。

该属性,等会细说。但是不过有没有发现这里绘制一个圆和我们前面使用canvas.drawCicle绘制的效果是一样的,没错,使用path添加图形再用canvas绘制出来,和直接调用canvas的绘制基础图形的效果是一样的,包括其他几个添加椭圆、添加矩形等。

画线(直线或曲线)

在前面通过addXXX()样式的API我们可以添加图形,可以发现添加的圆、椭圆、矩形都是一个完整的封闭图形,这当然无法完全发挥出Path的作用,而通过xxxTo()样式的API可以用来画线。

  1. lineTo画直线

这个API是从当前位置向目标位置画一条直线,默认起点是画布原点,还有就是rLineTo方法,这个方法中的坐标是当前位置的相对坐标,其中r就是relatively的意思,比如下面测试代码:

//画直线从原点到(100,100)
path.lineTo(100F,100F)
//以(100,100)为相当原点
path.rLineTo(0F,100F)
canvas?.drawPath(path, paint)

效果如下:

image.png

  1. quadTo画二阶贝塞尔曲线

关于什么是贝塞尔曲线,不是本章的重点,可以查看文章:

juejin.cn/post/701105…

而通过quadTo可以绘制二阶贝塞尔曲线,其中rQuadTo和之前一样,是按照相对位置,比较容易理解,测试代码如下:

//从原点绘制贝塞尔曲线,前后2个坐标分别是控制点,和终点
path.quadTo(0F,100F,100F,100F)
//以终点为相对原点,再绘制
path.rQuadTo(100F,0F,100F,100F)
canvas?.drawPath(path, paint)

效果如下:

image.png

  1. cubicTo画三阶贝塞尔曲线

这个和二阶类似,只要明白了贝塞尔曲线原理就非常容易理解,这里不再赘述。

  1. moveTo将画笔移动到目标位置

不论是画直线还是贝塞尔曲线,都是以当前位置作为起点,而不能指定起点,这里可以通过moveTo()或者rMoveTo()方法来改变当前画笔要绘制的绘制,比如下面测试代码:

//先画一条斜线
path.lineTo(100F,100F)
//移动画笔
path.rMoveTo(100F,0F)
//画竖线
path.rLineTo(0F,100F)
canvas?.drawPath(path, paint)

效果如下:

image.png

  1. arcTo画弧形

这个在前面绘制基本形状中,我们提到了绘制弧形或者扇形,其实也就是在绘制椭圆的基础上,定义扫过的角度,以及是否连接中心点来决定是绘制弧形还是扇形。

那为什么这里的绘制弧形要单独讲呢 因为这里仅仅是绘制弧形,是不会封闭的,所以没有扇形的情况。而在API中有个forceMoTo参数,表示绘制是画笔抬起来,还是直接拖过去。

为什么这里绘制弧形会不一样,原因我们可以想一下,前面画线的时候我们都是明确知道起点的,而绘制弧形就不一样了,我们只能确定弧形的椭圆位置以及扫过角度,具体弧形的起点不知道,所以需要额外多加一个参数,测试代码如下:

//先画一条斜线
path.lineTo(100F, 100F)
//绘制弧形
val rectF = RectF(100F,100F,300F,300F)
//画笔抬起来,中间不留痕迹
path.arcTo(rectF,0F,180F,true)
canvas?.drawPath(path, paint)

效果如下:

image.png

上面arcTo的最后一个参数设置为false的效果:

image.png

  1. close封闭当前子图形

该方法的作用非常简单,就是当前位置和绘制的起点绘制一条直线,测试代码如下:

path.lineTo(100F, 100F)
path.lineTo(100F, 200F)
//封闭子图形
path.close()
canvas?.drawPath(path, paint)

上述效果就相当于在最后又绘制了一条直线到起点:

image.png

注意这里有个概念叫做子图形,什么是子图形呢

子图形指的就是一次不间断的连线,比如前面添加子图形的addCicle就是一个封闭的子图形,而使用画线的API时,只要每一次画笔抬起,就标志着一个子图形的结束,以及一个新的子图形开始。

还有需要注意,不是所有子图形都需要close来封闭,当需要填充图形时,即Paint的Style设置为FILL或者FILL_AND_STROKE时,会自动封闭子图形,测试代码如下:

path.lineTo(100F, 100F)
path.lineTo(100F, 200F)
//自动封闭
canvas?.drawPath(path, paint)

上述把paint的style改为FILL,效果如下:

image.png

辅助的设置或计算

这类方法用的较少,就说其中一个方法:setFillType用来设置填充方式,在最前面我们绘制了2个圆而且有交叉,当我们把画笔设置FILL时,效果如下图:

//绘制2个交叉圆,且paint设置为FILL
path.addCircle(200F, 200F, 100F, Path.Direction.CW)
path.addCircle(300F,200F,100F,Path.Direction.CW)
canvas?.drawPath(path, paint)

image.png

这里我们可以修改一个path的fillType属性:

path.fillType = Path.FillType.EVEN_ODD
//绘制2个交叉圆,且paint设置为FILL
path.addCircle(200F, 200F, 100F, Path.Direction.CW)
path.addCircle(300F,200F,100F,Path.Direction.CW)
canvas?.drawPath(path, paint)

效果就可以变成下面:

image.png

这里的fillType一共有4种值,效果比较好理解:

fillType 解释
WINDING 表示全填充,默认就是这个,比如上图默认的效果
EVEN_ODD 表示交叉填充,具体效果如上图
INVERSE_WINDING 表示WINDING的反色版本,即WINDING的填充变成不填充部分
INVERSE_EVEN_ODD 表示EVEN_ODD的反色版本

所以搞明白前俩种是什么效果即可,后面俩种是反色版本,效果如下:

image.png

image.png

上面只是简单知道效果,现在我们来简单看一下其原理:

  • EVEN_ODD,即even-odd rule,奇偶原则:对于平面中的任意一点,向任意方向射出一条射线,如果这条射线和图像相交的次数(相交才算,相切不算)如果是奇数,则认为在图像内部,需要被涂色;如果是偶数,则认为在图像外部,不被涂色:

image.png

  • WINDING,即non-zero winding rule,非零环绕数原则:首先,它需要图形中所有线条都有绘制方向:

image.png

然后同样从平面的点向外任意方向射出一条射线,但计算规则不易,以0为初始值,对于射线和圆形的所有焦点,遇到每个顺时针的交点把结果加1,遇到每个逆时针的交点,把结果减1,最后把所有的交点都算上,结果为0则认为是在外部,不用涂色;不是0,则认为在图像内部,需要涂色。

image.png

这里就可以发现前面的结论中WINDING并不完全正确,因为前面图像我们都是以一个方向来绘制。

这里关于图像的方向,对于添加子图形的方法比如addCircle和addRect等,由参数dir来控制;对于画线的方法,比如lineTo,线的方向就是图像的方向。

所以完整的EVEN_ODD和WINDING效果如下,需要考虑图形方向:

image.png

画布操作

画布的操作可以让我们绘制出更多的效果,这里要注意一点,就是画布Canvas的概念,在最开始我们就说了虽然翻译为画布,其实它是绘制的规则,真正绘制是在屏幕上,所以当画布平移、裁剪等操作只对画布来说,对其View的大小和位置没有影响

而画布的操作大致可以分为以下几类,我们分别来看看。

画布变换

首先就是画布变换,对画布进行平移、缩放等。

平移

用于移动画布,实际上就是移动坐标系,测试代码如下:

//先在整个区域绘制为红色
canvas?.drawColor(context.resources.getColor(R.color.red))
//把画布平移到(100,100),画布原点就在这里
canvas?.translate(100F, 100F)
//以(100,100)为原点绘制蓝色矩形
canvas?.drawRect(0F, 0F, 200F, 200F, paint)

效果如下:

image.png

这里只需要记住一点即可,即平移画布不会影响原来View的位置和大小,对于需要依据原点来绘制的API,其原点就是平移后的原点,所以平移相当于移动坐标系。

缩放

关于缩放也是一样的,它只是缩放画布,和View的大小没关系。这里理解起来容易出错,我们来看个例子:

//画布平移到(200,200)
canvas?.translate(200F, 200F)
//绘制CanvasX轴
canvas?.drawLine(-200F,0F,200F,0F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
//绘制CanvasY轴
canvas?.drawLine(0F,-200F,0F,200F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
val rect = RectF(0F, 0F, 100F, 100F)
canvas?.drawRect(rect, paint)
//放大canvas
canvas?.scale(1.5F,1.5F)
//再绘制rect
canvas?.drawRect(rect, paint2)

这里我们把自定义View设置为宽高都是400px,然后画布平移到中心点,绘制俩条黑线当做坐标轴,然后创建一个宽高100px的Rect,先使用paint绘制一个红色的矩形,然后放大画布,再绘制一个,效果如下:

image.png

这里可以发现,在画布放大之前绘制的矩形并不会影响,而且画布放大之后,再绘制出来的图形都需要按比例放大,这就更说明一件事了:Canvas只是绘制的规则,它并不是一个真实的东西放在View上的

然后就是缩放的范围不仅仅是大于0,因为在我们的意识里,缩小到最小也就是0,但是其实可以为负数,当为负数的时候,就是按照缩放锚点进行反向缩放,比如下面测试代码:

canvas?.translate(200F, 200F)
//绘制CanvasX轴
canvas?.drawLine(-200F,0F,200F,0F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
//绘制CanvasY轴
canvas?.drawLine(0F,-200F,0F,200F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
val rect = RectF(0F, 0F, 100F, 100F)
canvas?.drawRect(rect, paint)
//放大canvas,这里是负数
canvas?.scale(-1.5F,-1.5F)
//再绘制rect
canvas?.drawRect(rect, paint2)

上述代码中,我们调用scale的值是负数,所以效果如下:

image.png

会发现是反向缩放。

旋转

和缩放类似,旋转也是以一个锚点来旋转,默认就是画布的原点,同样的是在画布旋转前绘制的图形并不会受到影响,之后影响旋转后画布的坐标系,测试代码如下:

//画布平移到(200,200)
canvas?.translate(200F, 200F)
//绘制CanvasX轴
canvas?.drawLine(-200F,0F,200F,0F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
//绘制CanvasY轴
canvas?.drawLine(0F,-200F,0F,200F,Paint().apply {
    strokeWidth = 4F
    color = context.resources.getColor(R.color.black_333)
})
val rect = RectF(0F, 0F, 100F, 100F)
canvas?.drawRect(rect, paint)
//旋转
canvas?.rotate(45F)
//再绘制rect
canvas?.drawRect(rect, paint2)

这里在旋转45度后,绘制一个矩形,如下:

image.png

这时Canvas的真实坐标系如下:

image.png

所以对于Canvas的操作,一定要明确在平移和旋转后坐标系是什么样子的。

错切

错切的意思是将画布在X方向倾斜a角度,在Y方向倾斜b角度,而错切的方法:

public void skew(float sx, float sy)

其中sx = tan a,sy = tan b,这里还是比较难理解的,可以直接如下理解:

将画布在X方向倾斜a角度,就相当于Y轴逆时针旋转a角度。将画布在Y轴方向倾斜b角度,就相当于X轴顺时针旋转b角度。

先绘制一张图片如下:

image.png

其中黑线表示坐标轴,这时调用:

canvas?.skew(1F, 0F)

即画布往X方向倾斜45度,即Y轴逆时针旋转45度:

image.png

这里可以发现坐标轴改变了,X轴方向不变,Y轴逆时针旋转了45度。

画布裁剪

画布裁剪即从画布上裁剪一块区域,之后仅仅能编辑该区域。这里注意画布默认是充满整个View,这里被裁剪后的其他区域并没有消失,而是后面再基于Canvas操作时,就是该小部分的画布了,测试代码如下:

val bitmap = BitmapFactory.decodeResource(context.resources, R.mipmap.pic)
canvas?.drawBitmap(bitmap, Matrix(), paint)
//裁剪画布
canvas?.clipRect(50F,50F,100F,100F)
canvas?.drawColor(context.getColor(R.color.red))

比如上面代码,在裁剪画布后,新画布就只有宽高50这么大,所以调用drawColor充满画布时,只有一小部分:

image.png

这里更可以发现之前说的,绘制内容是显示在屏幕上的,而Canvas是绘制的规则。

画布快照

在前面画布操作中,我们发现当调用画布的平移、缩放等操作时,画布的坐标系也会跟随变化,这就对后面继续绘制造成了很大麻烦,所以这里就需要一个能回到之前画布状态或者坐标系的方法了,这就是画布快照。

这里先看几个概念:

  1. 画布状态:当前画布经过的一些列操作。
  2. 状态栈:存放画布状态和图层的栈,后进先出。

image.png

  1. 画布的构成:由多个图层构成:

image.png

所以有如下结论:

  • 在画布上操作 = 在图层上操作。
  • 如无设置,绘制操作和画布操作默认是在默认图层上进行。
  • 在通常情况下,使用默认图层可以满足需求;若需要绘制复杂的内容(比如地图),则需要使用更多的图层。
  • 最终显示的结果 = 所有图层叠在一起的效果。

关于画布快照的用法,有如下:

保存当前画布状态

通过调用sava方法可以保存画布状态,即Canvas的设置参数。因为画布操作是不可逆的,而且会影响后续的步骤,如果需要回到之前画布的状态去执行下一次操作,就需要对画布的状态进行保存和回滚。

注意,这里只对Canvas进行回滚,即对Canvas的坐标、裁剪区域等信息进行回滚,而已经绘制过的内容是不受影响的。

回滚到上一次的状态

通过调用restore方法可以恢复上一次保存的画布状态,其实也就是从状态栈中,取出站顶的状态。

回滚到指定的状态

可以调用restoreToCount来恢复到之前指定的状态,因为状态栈中有多种情况,可以指定恢复某个状态的Canvas。

image.png

其实这个快照我们用的非常多,而平时的操作就是如下:

//操作前先保存
canvas?.save()
//一些列操作,比如平移、旋转等
//操作完,回退到之前状态
canvas?.restore()

总结

本篇文章都是Canvas使用的介绍,没有深入学习过多的难的知识点,后面文章再逐个分析其中的难点。文章部分内容参考扔无线朱凯的博客,有兴趣可以查看原博客:rengwuxian.com/tag/custom-…

笔者能力有限,如果发现错误,欢迎评论、指正。

猜你喜欢

转载自juejin.im/post/7121999016325447693
今日推荐