稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

降Compose十八掌之『利涉大川』| Canvas

任何一个GUI框架都会提供大量的预定义的UI部件,让开发者构建UI页面,但有些时候预定义的部件无法满足需求,这时就需要定制,甚至是自定义绘制的内容。对于Android开发者来说,这已经是家常便饭了,因为肯定有过用自定义View来实现一些特殊设计需求的经验。在Jetpack Compose中也有同样的方法来实现自定义绘制内容,今天就来学习一下。

使用Canvas来自定义内容

在Compose中, 我们用Canvas函数)来绘制自定义内容,可以把它理解成为自定义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
val textMeasurer = rememberTextMeasurer()

Canvas(modifier = Modifier.fillMaxSize()) {
    drawRect(Color.LightGray)

    drawText(
        textMeasurer = textMeasurer,
        text = "降Compose十八掌",
        topLeft = Offset(size.width / 4f, size.height / 2.2f)
    )

    drawCircle(
        color = Color.Magenta,
        radius = size.width / 10f,
        center = Offset(size.width / 1.8f, size.height / 3f)
    )
    drawCircle(
        color = Color.Yellow,
        radius = size.width / 12f,
        center = Offset(size.width / 1.6f, size.height / 4.5f)
    )
    drawCircle(
        color = Color.Green,
        radius = size.width / 14f,
        center = Offset(size.width / 1.46f, size.height / 7f)
    )
}

hello_canvas

坐标系统

坐标系统,与常见的GUI坐标系统,以及View的坐标系统都是一样的,左上角是原点(0,0),x轴向右,y轴向下。

绘图上下文DrawScope

仔细看Canvas函数,可以发现,写绘制指令的地方是一个尾部lambda,这是Compose中非常常见的一种设计方式。这个lambda被定义为DrawScope对象的一个扩展函数,所以在这个lambda中可以隐式的访问DrawScope对象。我们所使用的绘制指令,以及很多参数其实都是在通过this指针隐式的调用DrawScope。对于扩展函数不熟悉的同学可以去复习一下Kotlin中函数的一些高级用法

通过AndroidStudio的提示,也能看到隐式的this指针是一个DrawScope对象。

所以呢,当查找API文档时记得要去找DrawScope,而不是Canvas函数。其实Canvas就是一个封装的函数,也没啥东西。但还有一个略微底层一些的作为Graphics接口的对象Canvas,它与Android SDK中的Canvas对象是差不多的概念。

接下来我们重点看看如何使用绘制指令绘制出我们需要的内容。

画图形

图形(Shape)是最为常见的一类绘制目标,比如圆,椭圆,矩形,线,扇形等等。不难,看一眼就会用:

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
Canvas(modifier = Modifier.fillMaxSize()) {
    drawRect(Color.LightGray)

    drawOval(
        color = Color.Green,
        topLeft = Offset(50f, 50f),
        size=  Size(size.width / 10f, size.height / 12f)
    )

    drawLine(
        color = Color.Yellow,
        start = Offset(50 + size.width / 20f, 50f + size.height / 24f),
        end = Offset(size.width / 1.8f, size.height / 3f),
        strokeWidth = Stroke.DefaultMiter
    )

    drawCircle(
        color = Color.Magenta,
        radius = size.width / 5f,
        center = Offset(size.width / 1.8f, size.height / 3f)
    )

    drawPoints(
        color = Color.DarkGray,
        pointMode = PointMode.Points,
        strokeWidth = 50f,
        points = genPoints(size.width / 2f, size.height / 3f)
    )
}

shapes

画路径

路径(Path)是把一系列的数学指令转化为绘制命令,可以更为灵活的画一些曲线和图形,比如说画一个三角函数曲线:

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
@Composable
fun PathDemo() {
    val tm = rememberTextMeasurer()

    Canvas(modifier = Modifier.fillMaxSize()) {
        drawRect(Color.LightGray)

        drawText(tm, "cosine of [-PI, PI]", Offset(size.width / 3f, 60f))

        drawLine(
            color = Color.DarkGray,
            start = Offset(0f, size.height / 2f),
            end = Offset(size.width, size.height / 2f),
            strokeWidth = 3f
        )

        drawLine(
            color = Color.DarkGray,
            start = Offset(size.width / 2f, size.height / 3f),
            end = Offset(size.width / 2f, size.height * 2/ 3f),
            strokeWidth = 3f
        )

        drawPath(genPath(size.width, size.height), Color.Magenta, style = Stroke(width = 10f))
    }
}

fun genPath(width: Float, height: Float): Path {
    val slices = 60
    val path = Path();
    path.moveTo(0f, height / 3f)
    for (i in 1..slices) {
        val x0 = 2f * i.toFloat() * PI.toFloat() /  slices.toFloat() - PI.toFloat()
        val y0 = cos(x0) * height / 6f
        val x = i.toFloat() / slices.toFloat() * width
        val y = y0 + height / 2f
        path.lineTo(x, y)
        path.moveTo(x, y)
    }
    path.close()
    return path;
}

path 路径在绘制中是非常强大的功能,可以实现非常炫酷的动画效果。

画文字

文字是特别重要的UI元素,通常情况下我们都是过Text来展示文字,再与其他部件进行组合就能满足需求。一般来说不需要在自定义内容也使用文字,因为文字绘制一般来说比较复杂,因为像基线对齐,字体样式,字体大小等等,都需要考虑。文字部件Text内容其实也是用与自定义一样的更低层的API来实现的,但它把像对齐,样式,富文本等等都封装好了。

DrawScope也提供了绘制文字的函数,不过呢使用起来比较麻烦,需要详细计算文字所占用的区域大小,而文字的measure通常是非常麻烦的,因为像文字的字体以及文字大小都会影响到measure,因此measure要保存成为一个状态,这样当有影响到文字绘制的因素发生变化时,measure就会发生变化,进而触发Re-Composition:

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
@Composable
fun TextDemo() {
    val textMeasure = rememberTextMeasurer()

    Canvas(modifier = Modifier.fillMaxSize()) {
        val measuredText =
            textMeasure.measure(
                AnnotatedString(
                    text =
                    """
                        “降龙十八掌可说是【武学中的巅峰绝诣】,当真是无坚不摧、无固不破。虽招数有限,但每一招均具绝大威力。
                        北宋年间,丐帮帮主萧峰以此邀斗天下英雄,极少有人能挡得他三招两式,气盖当世,群豪束手。
                        当时共有“降龙廿八掌”,后经萧峰及他义弟虚竹子删繁就简,取精用宏,改为降龙十八掌,掌力更厚。
                        这掌法传到洪七公手上,在华山绝顶与王重阳、黄药师等人论剑时施展出来,王重阳等尽皆称道。”
                    """.trimIndent(),
                    spanStyle = SpanStyle(
                        fontSize = 20.sp,
                        fontWeight = FontWeight.ExtraBold,
                        brush = Brush.verticalGradient(listOf(Color.Magenta, Color.Cyan, Color.Blue))
                    )
                ),
                constraints = Constraints.fixed(
                    width = (size.width / 1.6f).toInt(),
                    height = (size.height / 2f).toInt()
                ),
                overflow = TextOverflow.Ellipsis,
                style = TextStyle(fontSize = 18.sp)
            )

        drawText(
            textLayoutResult = measuredText,
            topLeft = Offset(60f, 60f)
        )
    }
}

text

画图片

图片(Image)是与文字类似的非常重要的UI元素,像图标,头像,表情,背景图,Banner图,以及内容中的图像都属于图片元素,一般情况下用Image函数可以用来展示图片。

对于自定义绘制内容也可以使用图片,DrawScope中有提供绘制图片的方法:

1
2
3
4
val dogImage = ImageBitmap.imageResource(id = R.drawable.dog)
Canvas(modifier = Modifier.fillMaxSize(), onDraw = {
    drawImage(dogImage)
})

变幻

除了绘制以外,DrawScope还提供了一系列做变幻的函数。包括缩放,位移,旋转这些变幻直接作用于绘制指令上面。

缩放

使用DrawScope.scale.scale(kotlin.Float,kotlin.Float,androidx.compose.ui.geometry.Offset,kotlin.Function1))函数来对绘制指令进行缩放,参数是x轴方向和y轴方向的缩放倍数(大于1放大,小于1缩小),还可以指定中心坐标,默认是几何中心。

1
2
3
4
5
Canvas(modifier = Modifier.fillMaxSize()) {
    scale(scaleX = 10f, scaleY = 15f) {
        drawCircle(Color.Blue, radius = 20.dp.toPx())
    }
}

位移

DrawScope.translate.translate(kotlin.Float,kotlin.Float,kotlin.Function1))可以实现位移,参数是x方向或者y方向的距离。参数为正,是沿着坐标轴正向,为负就是反向。

1
2
3
4
5
Canvas(modifier = Modifier.fillMaxSize()) {
    translate(left = 100f, top = -300f) {
        drawCircle(Color.Blue, radius = 200.dp.toPx())
    }
}

旋转

DrawScope.rotate.rotate(kotlin.Float,androidx.compose.ui.geometry.Offset,kotlin.Function1))函数实现旋转,参数为正时是顺时针的角度,为负就是逆时针,可以指定中心点,默认是几何中心。

1
2
3
4
5
6
7
8
9
Canvas(modifier = Modifier.fillMaxSize()) {
    rotate(degrees = 45F) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
            size = size / 3F
        )
    }
}

画布尺寸调整

DrawScope.inset.inset(kotlin.Float,kotlin.Float,kotlin.Float,kotlin.Float,kotlin.Function1))函数来对DrawScope的画布进行调整,参数是周围四个方向的边距偏移量。

1
2
3
4
5
6
Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasQuadrantSize = size / 2F
    inset(horizontal = 50f, vertical = 30f) {
        drawRect(color = Color.Green, size = canvasQuadrantSize)
    }
}

这样调整后,inset内部的lambda中的绘制指令的尺寸size会受影响,size.width = width - 2 * horizontal,size.height = height - 2 * vertical,相当于是加了padding。

组合变幻

变幻除了可以单独使用,还可以组合起来使用,能更简便的实现变幻效果。使用DrawScope.withTransform.withTransform(kotlin.Function1,kotlin.Function1))来组合变幻:

1
2
3
4
5
6
7
8
9
10
11
12
Canvas(modifier = Modifier.fillMaxSize()) {
    withTransform({
        translate(left = size.width / 5F)
        rotate(degrees = 45F)
    }) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
            size = size / 3F
        )
    }
}

总结

今天主要学习了如何通过Canvas函数来实现自定义绘制内容,Canvas给我们了封装了一个包含有DrawScope的lambda,通过DrawScope提供的各种绘制指令可以实现我们的想要的自定义内容。可以自由的通过绘制图形,文字和图像,并且可以做变幻,以实现一些特效。相信通过今天的学习,足可以应付常见的自定义绘制需求。

参考资料

Comments