28、自定义绘制:Canvas、DrawScope、Path 的使用

自定义绘制,说白了就是让你在 Compose 里「画东西」。

我刚开始接触 Compose 时,觉得官方提供的 Button、Text、Image 已经够用了。直到有一天产品经理丢过来一个需求——画一个带渐变弧度的仪表盘进度条。嗯,那时候我才意识到,不会自定义绘制,很多 UI 效果根本做不出来。

Canvas 是什么?

Canvas 是 Compose 提供的一个可组合函数。它给你一块画布,让你在上面自由绘制。你可以画线、画圆、画路径、画文字,甚至做裁剪和变换。

它的签名很简单:

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

你只需要提供一个 Modifier 和一个绘制 lambda。这个 lambda 的接收者是 DrawScope,所有绘制操作都在它上面完成。

核心理解: Canvas 本身不负责绘制逻辑,它只是把 DrawScope 交给你。你写什么,它就画什么。

DrawScope:你的画笔和颜料盒

DrawScope 是一个接口,里面定义了大量绘制方法。我个人习惯把它们分成几类:

类别 常用方法 说明
形状 drawCircle, drawRect, drawRoundRect, drawOval 画基本几何图形
线条 drawLine, drawArc 画直线和弧线
路径 drawPath 画任意复杂路径
图像 drawImage 绘制 Bitmap
文字 drawText 绘制文本(需配合 TextMeasurer)

每个方法都接受一个 BrushColor 参数,用来控制填充和描边。你想想看,这就像你拿着不同颜色的笔在纸上画。

小技巧: 如果你需要渐变效果,用 Brush.horizontalGradientBrush.radialGradient 代替纯色。我在做天气 App 的日出日落弧线时,就用径向渐变模拟了天空的颜色过渡,效果很自然。

Path:画复杂形状的利器

Path 是一个路径对象。你可以把它想象成一支「不抬笔的画笔」——从起点开始,一路画过去,中间可以拐弯、画弧线、画曲线,最后闭合。

常用的 Path 操作:

  • moveTo(x, y):把笔抬起来,移动到新位置
  • lineTo(x, y):画一条直线到目标点
  • quadraticBezierTo(x1, y1, x2, y2):画二次贝塞尔曲线
  • cubicTo(x1, y1, x2, y2, x3, y3):画三次贝塞尔曲线
  • arcTo(rect, startAngle, sweepAngle, forceMoveTo):画弧线
  • close():闭合路径,回到起点

举个例子,画一个心形:

val path = Path().apply {
    moveTo(size.width / 2, size.height / 3)
    cubicTo(
        size.width * 0.3f, size.height * 0.1f,
        size.width * 0.1f, size.height * 0.4f,
        size.width / 2, size.height * 0.8f
    )
    cubicTo(
        size.width * 0.9f, size.height * 0.4f,
        size.width * 0.7f, size.height * 0.1f,
        size.width / 2, size.height / 3
    )
    close()
}

这段代码用两条三次贝塞尔曲线画出了心形的左右两半。你可能会问:「这些坐标怎么算出来的?」说实话,我一般不会手算。我会先用设计工具(比如 Figma 或 Sketch)把形状画出来,然后导出 SVG 路径,再转成 Compose 的 Path 代码。这样省时又准确。

注意: Path 的坐标是相对于 Canvas 左上角的。如果你在 Canvas 里做了 rotatetranslate 变换,Path 的坐标也会跟着变。我曾经在做一个旋转动画时,忘了考虑变换矩阵,结果路径画到了画布外面,找了半天 bug。

实战:画一个带刻度的圆弧进度条

光说不练假把式。我们来画一个实际的东西——一个圆弧进度条,带刻度标记。

@Composable
fun ArcProgressBar(
    progress: Float, // 0f ~ 1f
    modifier: Modifier = Modifier
) {
    Canvas(modifier = modifier.size(200.dp)) {
        val strokeWidth = 12.dp.toPx()
        val arcRect = Rect(
            strokeWidth / 2,
            strokeWidth / 2,
            size.width - strokeWidth / 2,
            size.height - strokeWidth / 2
        )

        // 画背景弧
        drawArc(
            brush = Brush.sweepGradient(listOf(Color.LightGray, Color.LightGray)),
            startAngle = 135f,
            sweepAngle = 270f,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = arcRect.topLeft,
            size = arcRect.size
        )

        // 画进度弧
        drawArc(
            brush = Brush.sweepGradient(
                listOf(Color(0xFF4CAF50), Color(0xFF2196F3)),
                start = 135f / 360f,
                end = (135f + 270f * progress) / 360f
            ),
            startAngle = 135f,
            sweepAngle = 270f * progress,
            useCenter = false,
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
            topLeft = arcRect.topLeft,
            size = arcRect.size
        )

        // 画刻度线
        val tickCount = 20
        val tickLength = 8.dp.toPx()
        val radius = arcRect.width / 2
        val center = Offset(size.width / 2, size.height / 2)

        for (i in 0 until tickCount) {
            val angle = Math.toRadians((135.0 + 270.0 * i / tickCount).toDouble())
            val innerPoint = Offset(
                center.x + (radius - strokeWidth / 2) * cos(angle).toFloat(),
                center.y + (radius - strokeWidth / 2) * sin(angle).toFloat()
            )
            val outerPoint = Offset(
                center.x + (radius - strokeWidth / 2 - tickLength) * cos(angle).toFloat(),
                center.y + (radius - strokeWidth / 2 - tickLength) * sin(angle).toFloat()
            )
            drawLine(
                color = if (i <= (tickCount * progress).toInt()) Color(0xFF2196F3) else Color.LightGray,
                start = innerPoint,
                end = outerPoint,
                strokeWidth = 2.dp.toPx()
            )
        }
    }
}

这段代码做了三件事:画背景弧、画进度弧、画刻度线。刻度线的颜色会根据进度变化——进度到了,刻度就变蓝。

关键点: 圆弧的起始角度是 135°,扫过 270°,这样留出了底部 90° 的缺口,看起来更像仪表盘。这个角度设计是我从汽车仪表盘上抄来的灵感。

SVG 知识体系图

下面这张图帮你理清 Canvas、DrawScope、Path 的关系:

Canvas DrawScope drawCircle / drawRect drawArc / drawLine drawPath drawText / drawImage Path 操作:moveTo / lineTo / cubicTo 最终产出:自定义 UI 组件

从图中可以看出,Canvas 是入口,DrawScope 是工具集,Path 是复杂形状的构建器。三者配合,你就能画出任何你想要的 UI。

避坑指南

我踩过不少坑,挑几个典型的说说:

  • 坐标单位混淆: Canvas 里默认单位是 px,不是 dp。如果你直接写 drawCircle(radius = 10),在不同密度屏幕上大小会不一样。记得用 .dp.toPx() 转换。
  • 性能问题: 不要在 onDraw 里创建新对象。Path、Paint、Brush 这些尽量在外部创建好,或者用 remember 缓存。我见过有人每次绘制都 new 一个 Path,结果帧率直接掉到 30。
  • 裁剪与变换: clipPathrotate 会影响后续所有绘制。如果你只想对某个元素做变换,记得用 withTransform 包裹,或者变换完后恢复。

个人习惯: 我会把复杂的绘制逻辑拆成多个小函数,每个函数只画一个部分。比如画仪表盘时,我会拆成 drawBackgroundArc()drawProgressArc()drawTicks()drawNeedle()。这样调试起来方便,也容易复用。

总结

自定义绘制其实没那么神秘。你只需要记住三件事:

  • 用 Canvas 拿到画布
  • 用 DrawScope 的方法画基本图形
  • 用 Path 画复杂形状

剩下的就是数学和审美了。嗯,数学不好也没关系,多抄抄设计稿的 SVG 路径,慢慢就熟练了。

公众号:蓝海资料掘金营,微信 deep3321