Jetpack Compose 自定义绘制:从易用 API 到原生 Canvas

386 阅读8分钟

前言

Compose 的绘制在思路上和安卓原生是一样的,只不过 Compose 提供的绘制 API 更为上层和简洁。但如果你要实现原生独有的绘制效果,比如多维旋转,就需要下沉到原生,使用原生的 API。

绘制代码在哪写?

在传统 View 系统中的自定义绘制是通过重写 onDraw() 方法实现的,在方法内部拿到 Canvas 对象,去做各种绘制操作。而 Compose 中的所有绘制工作全都发生在 DrawModifierNode 中,包括各种组件的绘制(如 Text 组件)和自定义绘制。

具体的函数有这几个:Modifier.drawBehind()Modifier.drawWithContent()Canvas()

Modifier.drawBehind

顾名思义,drawBehind 可以在内容的下方进行绘制,所以它非常适合用来给组件添加背景颜色、背景图片,或者任何你想要绘制在内容层之下的东西,你可以理解为当前在“背景层”进行绘制。

@Preview(showBackground = true )
@Composable
fun TextWithCustomBorderBackground() {
    Text(
        text = "Hello, Custom Draw!",
        modifier = Modifier
            .padding(16.dp)
            .drawBehind {

                // 绘制浅灰色填充矩形作为背景
                drawRect(color = Color.LightGray)

                // 绘制一个蓝色的框
                val strokeWidth = 2.dp.toPx() // 将dp转换为像素
                drawRect(
                    color = Color.Yellow,
                    topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
                    size = Size(
                        this.size.width - strokeWidth,
                        this.size.height - strokeWidth
                    ),
                    style = Stroke(width = strokeWidth) // 描边样式
                )
            }
    )
}

预览:

image.png

在上述示例中,我们不需要像原生那样先获取 Canvas 对象,再调用它的绘制函数,而是直接调用 drawRect 函数就可以进行绘制了。

这个函数是哪来的?其实是 lambda 表达式提供了 DrawScope 的上下文,在这个作用域中,我们可以调用各种绘制函数,比如 drawRect()drawImage()drawLine(),并且提供了各种属性来辅助绘制,比如 sizedensity

并且有些对参数敏感的人可能注意到了,我们调用的第一个 drawRect 函数并没有填矩形绘制的范围,而在原生中是需要的,不然不知道在哪绘制。

我们不需要填是因为它有默认值:

fun drawRect(
    color: Color,
    topLeft: Offset = Offset.Zero, // 组件的左上角
    size: Size = this.size.offsetSize(topLeft), // 组件的尺寸
    @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f,
    style: DrawStyle = Fill,
    colorFilter: ColorFilter? = null,
    blendMode: BlendMode = DefaultBlendMode
)

默认的范围就是所修饰的组件的范围。

drawWithContent

drawWithContent 可以让我们自由地控制绘制的顺序,靠的就是 drawContent() 函数,它的作用是绘制组件原有内容。

如果你在 drawContent() 之前写绘制代码,那么绘制的内容会被原有内容所覆盖;在 drawContent() 之后写绘制代码,其绘制的内容会覆盖在原有内容之上。

比如:

@Preview(showBackground = true)
@Composable
fun TextWithStrikethroughAndBackground() {
    Text(
        text = "重要通知",
        modifier = Modifier
            .padding(8.dp)
            .drawWithContent {
                // 在文字下方绘制背景
                drawRect(color = Color.Cyan)

                drawContent() // 先绘制文本内容 "重要通知"

                // 在文本上方绘制一条红线
                drawLine(
                    color = Color.Red,
                    start = Offset(0f, size.height / 2),
                    end = Offset(size.width, size.height / 2),
                    strokeWidth = 2.dp.toPx(),
                    cap = StrokeCap.Round
                )
            }
    )
}

预览:

image.png

Canvas

最后,如果你不是在现有的组件上添加绘制内容,而是要完全自定义绘制一个组件,你可以使用 Box,然后在它的上面去绘制,因为它的内部没有任何内容,相当于基于空白进行绘制。

@Preview
@Composable
fun BoxWithBackground() {
    Box(
        Modifier
            .size(320.dp, 180.dp)
            .drawWithContent {
                drawRect(Color.Green)
            }
    )
}

预览:

image.png

不过呢,Compose 给我们提供了一个 Composable 函数 Canvas,它提供了一个空白的画布(Canvas 的翻译就是画布),我们可以在它的第二个参数 onDraw lambda表达式中写绘制代码。

@Preview
@Composable
fun CanvasWithBackground() {
    Canvas(Modifier.size(320.dp, 180.dp)) {
        drawRect(Color.Green)
    }
}

效果还是一样的,并且点进去看源码:

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

发现内部使用的是 drawBehind 函数。

并且对比 SpacerBox 组件的源码,它们其实没有什么区别,调用 Layout 函数时,都没有传递内容组件。

@Composable
@NonRestartableComposable
fun Spacer(modifier: Modifier) {
    Layout(measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}
// ------------对比------------
@Composable
fun Box(modifier: Modifier) {
    Layout(measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}

只是测量策略有些许不同:当 Spacer 没有固定尺寸时,尺寸为 0 x 0,Box 则是使用最小尺寸。

private object SpacerMeasurePolicy : MeasurePolicy {

    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult {
        return with(constraints) {
            val width = if (hasFixedWidth) maxWidth else 0
            val height = if (hasFixedHeight) maxHeight else 0
            layout(width, height) {}
        }
    }
}
// ------------对比------------
internal val EmptyBoxMeasurePolicy = MeasurePolicy { _, constraints ->
    layout(constraints.minWidth, constraints.minHeight) {}
}

Compose 的绘制和原生的绘制对比,有何不同?

前面看到,在 Compose 中绘制函数的很多参数都有默认值,无需我们手动填写;而原生中许多绘制参数需要手动指定。

而且风格的设置(颜色、线条粗细、字体大小等),也可以说样式,通常直接作为 drawXxx() 绘制函数的参数;而不需要像原生那样,绘制时,要先创建一个 Paint 对象设置好属性,再把这个 Paint 对象作为参数传递给 Canvas 的各种 drawXxx() 方法。

最后,你不用显式获取到 Canvas 的对象才能去进行绘制,Compose 在底层为我们处理了 Canvas 的管理,我们无需直接操作底层的 Canvas 对象,只需直接调用 drawXxx() 函数即可(当然要在 DrawScope 上下文中)。

以上三点,其实都是 Compose 易用性的提升

何时需要“降级”到原生 Canvas?

某些特定场景下,我们依靠Compose 提供的现有 API 无法实现复杂的效果,这时,我们就可以“降级”,去访问更底层的原生 Canvas 对象来完成。

例如,现在我们要实现复杂的三维旋转效果:

image.gif

尝试使用 Compose 绘图 API 实现

首先绘制一张图片:

@Preview
@Composable
private fun CustomImage() {
    // 加载图片资源,创建 ImageBitmap 实例
    val image = ImageBitmap.imageResource(R.drawable.avatar)

    Canvas(Modifier.padding(12.dp).size(100.dp)) {
        // 在 Canvas 上绘制位图
        drawImage(
            image = image, 
            dstSize =  IntSize(size.width.roundToInt(),size.height.roundToInt()) // 目标尺寸为填满 Canvas
        )
    }
}

预览效果:

image.png

二维平面旋转

现在完成图片在二维平面上的旋转,只需要使用 DrawScope.rotate() 函数即可。

inline fun DrawScope.rotate(
    degrees: Float, // 旋转的角度,大于 0 表示顺时针旋转
    pivot: Offset = center, // 旋转的中心点,默认为当前 DrawScope 的中心
    block: DrawScope.() -> Unit // 该lambda表达式中所有绘制操作都会旋转
)

使用起来非常简单:

@Preview
@Composable
private fun TwoDimensionalRotationPicture() {
    val image = ImageBitmap.imageResource(R.drawable.avatar)

    Canvas(
        Modifier
            .padding(50.dp)
            .size(100.dp)
    ) {
        // 以 Canvas 中心为轴点顺时针旋转 45 度
        rotate(degrees = 45f) {
            drawImage(
                image = image,
                dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
            )
        }
        // rotate 函数之外的绘制将不受影响
    }
}

预览效果:

image.png

rotate 函数会把旋转的效果限制在它的作用域中,出了此作用域就没有旋转的效果。而原生的旋转完成后,你需要手动复原,不然旋转会影响到后续的绘制操作。

就像这样:

canvas.save() // 保存当前画布状态
canvas.rotate(45f) // 顺时针旋转45度
canvas.drawImage(..)
canvas.restore() // 恢复到保存之前的状态

尝试单轴三维旋转

现在我们完成了二维旋转,那三维旋转呢?

目前 Compose 是不支持三维旋转的,并且原生也是不支持的,它是靠android.graphics.Camera 类来实现多轴的三维旋转的。

不过呢,我们可以使用 Modifier.graphicsLayer() 函数来实现简单的单轴三维旋转(沿 X、Y、Z 轴独立旋转),像这样:

@Preview
@Composable
private fun SingleAxis3DRotationOfThePicture() {
    val image = ImageBitmap.imageResource(R.drawable.avatar)

    Canvas(
        Modifier
            .padding(15.dp)
            .size(100.dp)
            .graphicsLayer {
                rotationX = 45f  // 绕 X 轴旋转 45 度
                rotationY = -45f // 尝试同时绕 Y 轴旋转
            }
    ) {

        drawImage(
            image = image,
            dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt())
        )
    }
}

效果:

image.png

同时设置多个旋转轴时,你会发现旋转结果与我们的直觉不符,因为它底层在处理多轴旋转时,是依次独立地应用每个轴的旋转。

也就是说,第二次旋转是在第一次旋转已经改变了的坐标系上进行的,而不是所有旋转同时作用于原始的图形。这种按顺序旋转坐标系的方式,可能导致组合的旋转效果不平滑,也难以完成真正的多轴同步旋转效果。

下沉到原生 Canvas 实现复杂三维旋转

这时,我们就要下沉到原生的 Canvas

使用 DrawScope.drawIntoCanvas 方法,就可以使用更底层的 androidx.compose.ui.graphics.Canvas 实例,通过它的 nativeCanvas 属性,就能拿到原生的 android.graphics.Canvas

获取到原生的 Canvas,配合上 android.graphics.Camera 类来实现复杂的多维旋转。

@Preview
@Composable
private fun MultiAxis3DRotationOfThePicture() {
    val image = ImageBitmap.imageResource(R.drawable.avatar)
    // 创建一个 Paint 对象,用于绘制
    val paint by remember { mutableStateOf(androidx.compose.ui.graphics.Paint()) }
    // 创建一个 Camera 对象,用于3D变换
    val camera by remember { mutableStateOf(android.graphics.Camera()) }.apply {
        value.rotateX(45f) // 初始化时就让相机沿X轴旋转45度
    }

    Canvas(
        Modifier
            .size(100.dp)
    ) {

        drawIntoCanvas { composeCanvas ->
            // composeCanvas 是 androidx.compose.ui.graphics.Canvas 类型
            // 它包装了原生的 android.graphics.Canvas

            // 获取原生的 Canvas 对象
            val nativeCanvas = composeCanvas.nativeCanvas

            composeCanvas.translate(dx = size.width / 2,dy = size.height / 2) // 让图像处于左上角

            composeCanvas.rotate(degrees = -45f)
            camera.applyToCanvas(nativeCanvas) // 将 Camera 的旋转应用到原生 Canvas
            composeCanvas.rotate(degrees = 45f)


            composeCanvas.translate(dx = -size.width / 2,dy = -size.height / 2) // 复原图像的位置

            // 基于旋转的绘制
            composeCanvas.drawImageRect(
                image = image,
                dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                paint = paint
            )
        }


    }
}

预览效果:

image.png

最后再加上动画效果。

注意 android.graphics.Camera 属性的每一次设置,效果是累加的。所以我们要在应用 Camera 变换之前,调用camera.save()保存其当前状态,设置完旋转角度并应用到 Canvas后,调用camera.restore()复原状态,保证每次旋转时,都是从未旋转的状态开始的,而不是在上一次旋转的基础上累积。

@Preview(showBackground = true)
@Composable
private fun MultiAxis3DRotationOfThePicture() {
    val image = ImageBitmap.imageResource(R.drawable.avatar)
    val paint by remember { mutableStateOf(androidx.compose.ui.graphics.Paint()) }
    val camera by remember { mutableStateOf(android.graphics.Camera()) }
    val rotationAnimatable = remember { Animatable(0f) }


    LaunchedEffect(Unit) {
        rotationAnimatable.animateTo(
            targetValue = 360f,
            animationSpec = infiniteRepeatable(tween(2000))
        )
    }

    Canvas(
        Modifier
            .size(100.dp)
    ) {

        drawIntoCanvas { composeCanvas ->
            // composeCanvas 是 androidx.compose.ui.graphics.Canvas 类型
            // 它包装了原生的 android.graphics.Canvas

            // 获取原生的 Canvas 对象
            val nativeCanvas = composeCanvas.nativeCanvas

            composeCanvas.translate(dx = size.width / 2, dy = size.height / 2) // 让图像处于左上角
            composeCanvas.rotate(degrees = -45f)

            // 使用 Camera 进行三维旋转
            camera.save() // 保证旋转时的角度总为 0
            camera.rotateX(rotationAnimatable.value)
            camera.applyToCanvas(nativeCanvas) // 将 Camera 的旋转应用到原生 Canvas
            composeCanvas.rotate(degrees = 45f)

            camera.restore()

            composeCanvas.translate(dx = -size.width / 2, dy = -size.height / 2) // 复原图像的位置,这两步可以完成

            // 基于旋转的绘制
            composeCanvas.drawImageRect(
                image = image,
                dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()),
                paint = paint
            )

        }


    }
}

运行效果:

image.gif

以上的重点不是在于三维旋转,而是如何下沉到原生。通过 drawIntoCanvas { it.nativeCanvas } 函数,我们获得了原生的 Canvas,用来实现复杂的动画效果。