【译】探索Jetpack Compose之Canvas:强大的绘图能力

3,531 阅读6分钟

Exploring Jetpack Compose Canvas: the power of drawing
作者:Julien Salvi

原文链接

1_R_lY2xx-jO8vIxat0XH7qA.png

本文我将向大家分享一下我使用Jetpack Compose里面Canvas的经验。Jetpack Compose想必大家都了解了,它是Google推出的一个新的UI工具集。Android Dev Challenge #2让我有机会学到了很多关于Canvas的东西,并且知道怎么运用Canvas的优点用非常优雅的方式绘制图形和制作动画。

大部分的代码示例都是基于下面这个工程: github-TimePack-CountPose

免责声明:示例代码都是基于Compose 1.0.0-beta02,这些API可能在将来发生变化。

熟悉Canvas

如果你对Android View的Canvas熟悉的话,看到Jetpack Compose的Canvas你也不会感到无从下手。所有的函数名都是一样的,在处理Path这个API的时候,甚至Compose的Canvas更加明确,比如relativeQuadraticBezierTo() 替代 rQuadto() 去绘制一段曲线。

如果你对原生的Android Canvas不熟悉的话,我推荐你去看下Rebecca Franks那篇关于Canvas非常的介绍。Getting Started with Android Canvas Drawing

Jetpack Compose里有一个Canvas的Composable,这个类在UI组件库里面,它能让你的APP释放强大的绘图能力。我会绘制一个笑脸,用一些简单的图形,比如圆形,弧形,矩形,来展示Canvas的能力。

@Composable
fun SmileyFaceCanvas(
    modifier: Modifier
) {
    Canvas(
        modifier = modifier.size(300.dp),
        onDraw = {
            // Head
            drawCircle(
                Brush.linearGradient(
                    colors = listOf(greenLight700, green700)
                ),
                radius = size.width / 2,
                center = center,
                style = Stroke(width = size.width * 0.075f)
            )

            // Smile
            val smilePadding = size.width * 0.15f
            drawArc(
                color = red700,
                startAngle = 0f,
                sweepAngle = 180f,
                useCenter = true,
                topLeft = Offset(smilePadding, smilePadding),
                size = Size(size.width - (smilePadding * 2f), size.height - (smilePadding * 2f))
            )

            // Left eye
            drawRect(
                color = dark,
                topLeft = Offset(size.width * 0.25f, size.height / 4),
                size = Size(smilePadding, smilePadding)
            )
            
            // Right eye
            drawRect(
                color = dark,
                topLeft = Offset((size.width * 0.75f) - smilePadding, size.height / 4),
                size = Size(smilePadding, smilePadding)
            )
        }
    )
}

在Canvas的onDraw lambda表达式里面我们可以访问到DrawScope。这个scope可以让我们绘制任意我们想绘制的东西。记住一点,Canvas的原始坐标(x=0,y=0)在左上角。

1_0ncG3mNvEQkEkxEMZ7fhAw.png

为了绘制笑脸的头部,我们会用stroke的样式绘制一个圆形。如果让style为空的话,会有一个默认的填充。所有的绘制方法都支持传一个Color或者Brush(用来添加一组颜色的渐变)。怎么设置半径呢?我们通过DrawScope拿到了当前绘制环境的尺寸size,这样我们就可以根据当前组件的尺寸来控制半径了。center属性支持传Offset来设置图形在Canvas上面的位置。

然后,我们绘制一个单一的颜色绘制一个弧形的嘴,同样眼睛用矩形也是这么绘制的。现在我们的笑脸已经可以展示到屏幕上了:

1_b4PLQXZbgsnhGfjFMZC1dg.png

DrawScope内能使用很多绘制方法,下面列举截个用到的函数:

  • drawCircle() // draws a circle at given coordinates
  • drawArc() // draws an arc scaled to fit inside a given rectangle
  • drawImage() // draws an ImageBitmap in the canvas
  • drawPoints() // draws a sequence of points
  • drawPath() // draws a path with a given color 还有很多......现在让我们看下怎么让绘制在Canvas上面的元素动起来。

Canvas和动画

了解完一些Canvas的基础知识,再让我们看看实现一些更加复杂的动画和UI。在Android Dev Challenge #2,我决定开发一个带波浪的动画,一边播放波浪动画,一边慢慢的往下平移,等时间结束正好到底。

DrawScope为直接给Canvas上的元素添加动画添加了非常好的支持。你可以使用平移(translation)、旋转(rotation)或者尺寸缩放(scale)。为了实现计时器的波浪动画,我们就准备使用平移动画。

首先为了实现动画,我们定义了两种类型的AnimationState。为了实现一个不停的波浪动画,我们借助rememberInfiniteTransition()将一个浮点数从0到1无限循环。然后我们通过animateFloat()和相关的特性(specification)将动画的值给暴露出来。

对于所有有限的动画,我们都是直接用AnimationState相关的函数,比如animateFloatAsState,animateColorAsState...就能设置动画的目标值并且定义动画的特性(Specification)。

val deltaXAnim = rememberInfiniteTransition()
val dx by deltaXAnim.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(500, easing = LinearEasing)
    )
)

val screenWidthPx = with(LocalDensity.current) {
    (LocalConfiguration.current.screenHeightDp * density)
}
val animTranslate by animateFloatAsState(
    targetValue = screenWidthPx,
    animationSpec = TweenSpec(10000, easing = LinearEasing)
)

val waveHeight by animateFloatAsState(
    targetValue = 0f,
    animationSpec = TweenSpec(10000, easing = LinearEasing)
)

定义好动画状态之后,我们可以开始实现波浪的动画了。为了画这个波浪,我们准备用Path去添加一个贝塞尔曲线,看起来会有点像正弦函数。波浪画好了之后呢,我们需要用平移translate() lambda函数把波浪包裹起来,这个平移在DrawScope下就能操作,然后把动画状态的值动态去修改波浪的top像素值。

Canvas(
    modifier = modifier.size(300.dp),
    onDraw = {
        translate(top = animTranslate) {
            drawPath(path = path, color = animColor)
            path.reset()
            val halfWaveWidth = waveWidth / 2
            path.moveTo(-waveWidth + (waveWidth * dx), originalY.dp.toPx())

            for (i in -waveWidth..(size.width.toInt() + waveWidth) step waveWidth) {
                path.relativeQuadraticBezierTo(
                    halfWaveWidth.toFloat() / 2,
                    -waveHeight,
                    halfWaveWidth.toFloat(),
                    0f
                )
                path.relativeQuadraticBezierTo(
                    halfWaveWidth.toFloat() / 2,
                    waveHeight,
                    halfWaveWidth.toFloat(),
                    0f
                )
            }

            path.lineTo(size.width, size.height)
            path.lineTo(0f, size.height)
            path.close()
        }
    }
)

下面是一个完整的动画效果。

oVN0vk.gif

用原生的Canvas绘制文本内容

目前还不能直接在Jetpack Compose的Canvas上绘制Text。为了绘制文本,我们只能用Android 框架原生的Canvas去绘制文本信息。在onDraw的lambda里面,可以通过调用drawIntoCanvas来拿到nativeCanvas来操作嵌入的画布(这个非常有用,可以用来复用一些之前开发好的逻辑)然后你就可以调用原生画布所有的相关的方法了,比如drawText,drawVertices等等。

为了给文本加上样式,必须用到Paint了。因为我们现在用的是原生的画布,不能直接用Compose自带的Paint的drawText函数了。为了拿到原生paint的实例,我们可以通过asFrameworkPaint()这个函数来处理android.graphics.Paint

下面是怎么在原生画布上绘制文本信息的代码片段:

val textPaint = Paint().asFrameworkPaint().apply {
    isAntiAlias = true
    textSize = 24.sp.toPx()
    color = android.graphics.Color.BLUE
    typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
}
Canvas(
    modifier = modifier.fillMaxSize(),
    onDraw = {
        drawIntoCanvas {
            it.nativeCanvas.drawText(
                "My Jetpack Compose Text"
                0f,            // x-coordinates of the origin (top left)
                120.dp.toPx(), // y-coordinates of the origin (top left)
                textPaint
            )
        }
    }
)

下面是加到示例代码上的一个实现效果。

1_M0npWQhxJc_8w8GyDZyqVw.png

你可以使用所有Jetpack Compose Canvas所有的动画(平移、旋转、缩放等)然后包装上drawIntoCanvas就能给绘制的内容添加动画了。

Canvas(
    modifier = modifier.fillMaxSize(),
    onDraw = {
        translate(top = animTranslate * 0.92f) {
            scale(scale = if (timePackViewModel.alertState.value!!) animAlertScale else 1f) {
                drawIntoCanvas {
                    it.nativeCanvas.drawText(
                        "My Jetpack Compose Text",
                        0f,
                        120.dp.toPx(),
                        textPaint
                    )
                }
            }
        }
    }
)

使用Canvas可能会解锁很多设计的可能性!一开始可能做可能很简单,但是做着做着可能就会发现想要实现复杂的路径绘制的时候需要比较复杂的数学算法。Jetpack Compose Canvas提供了很多类似的支持,你可以尽可能利用起来。

最后非常感谢Annyce Davis帮忙审核以及反馈的很多很好建议。