14、Canvas 绘图:Canvas 组件基础,drawLine、drawCircle、drawPath,自定义绘制组件。
说实话,Jetpack Compose 里的 Canvas,是我个人觉得最「解压」的一个组件。你想想看,平时我们写 UI 都是堆砌各种现成的控件,但到了 Canvas 这里,你就像拿到了一张白纸和一支画笔,想画什么全凭自己。
我记得刚接触 Compose 那会儿,有个需求是要画一个自定义的仪表盘。用传统 View 的 onDraw 写习惯了,总觉得 Compose 的 Canvas 会不会很别扭?结果上手之后发现,嗯,真香。它把 Canvas 封装成了一个 Composable 函数,你只需要在 drawScope 里调用各种绘制 API 就行,比传统 View 的 Canvas 用起来更直观。
Canvas 组件基础
Canvas 在 Compose 里其实就是一个 Composable,它的签名很简单:
@Composable
fun Canvas(
modifier: Modifier,
onDraw: DrawScope.() -> Unit
)
你只需要给它一个 modifier 控制大小和布局,然后在 onDraw 这个 lambda 里尽情发挥。这个 lambda 的接收者是 DrawScope,它提供了所有绘制方法,比如 drawLine、drawCircle、drawPath 等等。
我刚开始用的时候犯过一个低级错误——没给 Canvas 设置尺寸。结果画了半天,屏幕上啥也没有。后来才反应过来,Canvas 默认大小是 0,你必须通过 Modifier 给它指定宽高,比如 Modifier.size(200.dp)。
drawLine:画一条线
先来个最简单的,画一条直线。API 长这样:
fun drawLine(
color: Color,
start: Offset,
end: Offset,
strokeWidth: Float = 0f,
cap: StrokeCap = StrokeCap.Butt,
pathEffect: PathEffect? = null
)
举个例子,从左上角到右下角画一条红线:
Canvas(modifier = Modifier.size(200.dp)) {
drawLine(
color = Color.Red,
start = Offset(0f, 0f),
end = Offset(size.width, size.height),
strokeWidth = 4f
)
}
这里 size 是 Canvas 的当前尺寸,在 DrawScope 里可以直接访问。我个人习惯把 strokeWidth 设成 dp 转 px 后的值,这样在不同屏幕密度下表现一致。
你可能会问,cap 参数是干嘛的?它控制线条端点的形状。默认是 StrokeCap.Butt(平头),你也可以改成 Round(圆头)或 Square(方头)。我在项目中画进度条时,就喜欢用 Round,看起来更圆润舒服。
drawCircle:画一个圆
画圆就更简单了:
fun drawCircle(
color: Color,
radius: Float,
center: Offset = this.center,
style: DrawStyle = Fill,
... // 其他可选参数
)
默认圆心在 Canvas 的中心,半径你指定。比如画一个蓝色的实心圆:
Canvas(modifier = Modifier.size(200.dp)) {
drawCircle(
color = Color.Blue,
radius = size.minDimension / 2
)
}
注意 style 参数,默认是 Fill(填充),你也可以改成 Stroke(描边)。我曾经在做一个加载动画时,需要画一个空心圆环,就是用 Stroke 配合 strokeWidth 实现的:
drawCircle(
color = Color.Gray,
radius = 50.dp.toPx(),
style = Stroke(width = 6.dp.toPx())
)
.toPx() 把 dp 转成像素。Compose 的 DrawScope 提供了 dp.toPx() 扩展函数,很方便。
drawPath:画任意路径
这才是 Canvas 的灵魂。Path 可以组合直线、曲线、圆弧,画出任何你想要的形状。基本用法:
fun drawPath(
path: Path,
color: Color,
style: DrawStyle = Fill,
...
)
先创建一个 Path 对象,然后调用它的方法:
val path = Path().apply {
moveTo(0f, 0f) // 移动到起点
lineTo(100f, 0f) // 画直线到 (100,0)
lineTo(100f, 100f) // 画直线到 (100,100)
close() // 闭合路径,回到起点
}
Canvas(modifier = Modifier.size(200.dp)) {
drawPath(path, color = Color.Green, style = Stroke(width = 4f))
}
这段代码画了一个直角三角形。如果你用 Fill 样式,它会自动填充闭合区域。
Path 还支持贝塞尔曲线,比如 quadraticBezierTo 和 cubicBezierTo。我在项目中画过波浪动画,就是用 cubicBezierTo 生成平滑的曲线路径,然后配合动画不断偏移。
自定义绘制组件
光画几个基本图形还不够,实际项目中我们往往需要封装成可复用的组件。比如做一个自定义的「温度计」组件:
@Composable
fun Thermometer(
temperature: Float, // 0f ~ 100f
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.size(60.dp, 200.dp)) {
val barWidth = 20.dp.toPx()
val barHeight = size.height - 40.dp.toPx()
val barLeft = (size.width - barWidth) / 2
val barTop = 0f
// 画背景条(灰色)
drawRoundRect(
color = Color.LightGray,
topLeft = Offset(barLeft, barTop),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barWidth / 2)
)
// 画温度填充(红色)
val fillHeight = barHeight * (temperature / 100f)
drawRoundRect(
color = Color.Red,
topLeft = Offset(barLeft, barTop + barHeight - fillHeight),
size = Size(barWidth, fillHeight),
cornerRadius = CornerRadius(barWidth / 2)
)
// 画底部圆球
val circleRadius = 18.dp.toPx()
drawCircle(
color = Color.Red,
radius = circleRadius,
center = Offset(size.width / 2, barTop + barHeight + circleRadius)
)
}
}
这个组件接收一个 temperature 参数,动态绘制水银柱的高度。你可以在外部用 animateFloatAsState 驱动它,实现平滑动画。
我建议你把常用的绘制逻辑封装成这样的组件,而不是在每个页面里重复写 Canvas 代码。比如圆环进度条、折线图、仪表盘,都可以做成独立的 Composable,方便复用。
知识体系总览
下面这张图帮你理清 Canvas 绘制的核心脉络:
避坑指南
最后分享几个我实际踩过的坑:
- 坐标单位混淆:Canvas 内部默认使用像素,但 Compose 的布局参数通常是 dp。记得用
dp.toPx()转换,否则在不同密度屏幕上会变形。 - Path 不闭合:如果你用
Stroke画路径,最后一段不会自动连回起点。需要手动调用close()或者画一条线回去。 - 性能问题:不要在 Canvas 的
onDraw里创建大量对象(比如 Path),每次重组都会重新创建。把 Path 定义在 Composable 外部或者用remember缓存起来。 - 动画重绘:如果你用
animateFloatAsState驱动 Canvas 绘制,记得把动画值作为参数传进去,Compose 会自动触发重组。不要手动调用invalidate(),那是传统 View 的做法。
嗯,Canvas 这块内容其实不难,关键是多动手画一画。你可以在一个 Composable 里同时画线、圆、路径,组合出各种有趣的图形。下次遇到自定义 UI 需求,别急着找第三方库,试试自己用 Canvas 画一个,说不定更有成就感。