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) |
每个方法都接受一个 Brush 或 Color 参数,用来控制填充和描边。你想想看,这就像你拿着不同颜色的笔在纸上画。
小技巧: 如果你需要渐变效果,用 Brush.horizontalGradient 或 Brush.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 里做了 rotate 或 translate 变换,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 是工具集,Path 是复杂形状的构建器。三者配合,你就能画出任何你想要的 UI。
避坑指南
我踩过不少坑,挑几个典型的说说:
- 坐标单位混淆: Canvas 里默认单位是 px,不是 dp。如果你直接写
drawCircle(radius = 10),在不同密度屏幕上大小会不一样。记得用.dp.toPx()转换。 - 性能问题: 不要在
onDraw里创建新对象。Path、Paint、Brush 这些尽量在外部创建好,或者用remember缓存。我见过有人每次绘制都 new 一个 Path,结果帧率直接掉到 30。 - 裁剪与变换:
clipPath和rotate会影响后续所有绘制。如果你只想对某个元素做变换,记得用withTransform包裹,或者变换完后恢复。
个人习惯: 我会把复杂的绘制逻辑拆成多个小函数,每个函数只画一个部分。比如画仪表盘时,我会拆成 drawBackgroundArc()、drawProgressArc()、drawTicks()、drawNeedle()。这样调试起来方便,也容易复用。
总结
自定义绘制其实没那么神秘。你只需要记住三件事:
- 用 Canvas 拿到画布
- 用 DrawScope 的方法画基本图形
- 用 Path 画复杂形状
剩下的就是数学和审美了。嗯,数学不好也没关系,多抄抄设计稿的 SVG 路径,慢慢就熟练了。