Visualização do Android | Detalhes da tela

prefácio

Os desenvolvedores que estão familiarizados com a visualização personalizada do Android devem estar familiarizados com o Canvas. Quando usamos a visualização personalizada, a terceira etapa é desenhar, e os seguintes métodos serão reescritos:

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

Podemos obter os resultados que desejamos usando várias APIs de instâncias do Canvas, mas não fizemos um combing sistemático. Este artigo dará uma boa olhada nisso.

texto

Antes de tudo, temos que deixar uma coisa clara, ou seja, esse Canvas, que se traduz diretamente no significado de canvas, mas aqui entendemos isso como uma regra de desenho , que é usada para especificar o conteúdo do desenho.

Então o conteúdo é realmente desenhado na tela, e o Canvas apenas especifica as regras para desenhar o conteúdo.A localização do conteúdo é determinada pelas coordenadas, e as coordenadas são em termos da tela.

Criação de objetos de tela

Como o Canvas é uma regra de desenho, de onde vem esse Canvas? Existem quatro maneiras de obter uma instância do Canvas.

  1. Por meio de um construtor vazio.

código mostrar como abaixo:

val canvas = Canvas()

Embora uma instância do Canvas possa ser obtida através de um construtor vazio, o conteúdo desenhado pelo Canvas precisa ser salvo em um container, e este container é Bitmap, e um objeto Bitmap pode ser definido:

canvas.setBitmap(bitmap)
  1. Através do construtor com Bitmap.

Neste caso, o Bitmap salvará as informações desenhadas pelo Canvas, o código é o seguinte:

val canvas = Canvas(bitmap)
  1. Substituir visualização.onDraw

Este método também é o método que mais usamos. Implementamos ele sobrescrevendo o método onDraw ao usar uma View personalizada. Aqui podemos pensar onde o conteúdo desenhado pelo Canvas é exibido. Quando uma View é medida e disposta, ela pode ser considerada como um retângulo em branco. Neste momento, o Canvas é o objeto Canvas correspondente à View:

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

Há muitos detalhes aqui, como a origem do Canvas correspondente a esta View, e como ele corresponde a esta View um-a-um. Exploraremos e analisaremos esta parte do conteúdo em um artigo posterior.

  1. via SurfaceView

Este método pode ser usado quando usamos SurfaceView, o código é o seguinte:

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

Em relação ao mecanismo Surface do Android, vamos explorá-lo e analisá-lo mais adiante no artigo.

Pentear 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-…

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

Guess you like

Origin juejin.im/post/7121999016325447693