Canvas与绘图:从零开始画点东西
说实话,Jetpack Compose 里的 Canvas 是我最喜欢的功能之一。为什么?因为它让你能完全掌控屏幕上的每一个像素。你想想看,在 XML 时代,想画个自定义 View 得继承、重写 onDraw、处理测量... 一堆模板代码。现在呢?一个 Canvas composable 就搞定了。
我个人习惯把 Canvas 看作是「一张无限大的画布」。你可以在上面画线、画圆、画矩形,甚至用 Path 画出任意形状。这节课,我们就从最基础的绘图 API 开始,一步步掌握 Canvas 的核心用法。
Canvas composable 的基本用法
先看一个最简单的例子:
@Composable
fun SimpleCanvas() {
Canvas(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray)
) {
// 在这里绘制内容
drawCircle(Color.Red, radius = 50.dp.toPx())
}
}
嗯,这里要注意:Canvas 内部是一个 DrawScope 上下文。所有绘图操作都在这个作用域里执行。坐标原点在 Canvas 的左上角,x 轴向右,y 轴向下。
drawLine:画一条线
画线是最基础的操作。我在项目中经常用它来做分隔线、网格线或者图表中的趋势线。
Canvas(modifier = Modifier.size(200.dp)) {
val canvasWidth = size.width
val canvasHeight = size.height
drawLine(
color = Color.Blue,
start = Offset(0f, canvasHeight / 2),
end = Offset(canvasWidth, canvasHeight / 2),
strokeWidth = 4f,
cap = StrokeCap.Round // 线帽样式
)
}
参数不多,但有几个坑:
strokeWidth的单位是 px,不是 dp。记得用dp.toPx()转换cap控制线两端的形状,Round 会让端点变圆润pathEffect可以画虚线,比如PathEffect.dashPathEffect(floatArrayOf(10f, 10f))
2.dp.toPx()。
drawCircle:画一个圆
画圆比画线稍微复杂一点,但也不难。核心参数是圆心坐标和半径。
Canvas(modifier = Modifier.size(200.dp)) {
val centerX = size.width / 2
val centerY = size.height / 2
// 实心圆
drawCircle(
color = Color(0xFFFF6B35),
radius = 60.dp.toPx(),
center = Offset(centerX, centerY)
)
// 空心圆(只画边框)
drawCircle(
color = Color.Black,
radius = 60.dp.toPx(),
center = Offset(centerX, centerY),
style = Stroke(width = 3.dp.toPx())
)
}
这里有个小技巧:用 style = Stroke(...) 画边框,用默认的 Fill 画实心。两者可以叠加,做出描边效果。
drawRect:画矩形
矩形在 UI 中太常见了。进度条、卡片背景、按钮... 本质上都是矩形。
Canvas(modifier = Modifier.size(200.dp)) {
// 普通矩形
drawRect(
color = Color(0xFF4CAF50),
topLeft = Offset(20.dp.toPx(), 20.dp.toPx()),
size = Size(160.dp.toPx(), 80.dp.toPx())
)
// 圆角矩形
drawRoundRect(
color = Color(0xFF2196F3),
topLeft = Offset(20.dp.toPx(), 120.dp.toPx()),
size = Size(160.dp.toPx(), 60.dp.toPx()),
cornerRadius = CornerRadius(12.dp.toPx())
)
}
你想想看,如果不用 Canvas,要实现圆角矩形得用 Modifier.clip(RoundedCornerShape(...))。但 Canvas 给了你更底层的控制权,比如可以只画左上角圆角、其他角直角。
drawPath:画出任意形状
Path 才是 Canvas 的灵魂。它能画出任何你想要的形状——曲线、贝塞尔、多边形...
Canvas(modifier = Modifier.size(200.dp)) {
val path = Path().apply {
// 移动到起点
moveTo(100.dp.toPx(), 20.dp.toPx())
// 画一条线到右下
lineTo(180.dp.toPx(), 160.dp.toPx())
// 画一条线到左下
lineTo(20.dp.toPx(), 160.dp.toPx())
// 闭合路径
close()
}
drawPath(
path = path,
color = Color(0xFFE91E63),
style = Stroke(width = 4.dp.toPx())
)
}
Path 的 API 很丰富:
moveTo(x, y):移动画笔,不画线lineTo(x, y):从当前位置画直线到目标点quadraticBezierTo():二次贝塞尔曲线cubicTo():三次贝塞尔曲线addCircle()、addRect():添加子形状
Path 动画:让图形动起来
静态的 Path 已经很强了,但加上动画才真正酷。我记得在做一个「加载中」动画时,用 Path 动画实现了路径描边效果,用户反馈说「这动画很丝滑」。
核心思路是用 Animatable 或 animateFloatAsState 控制一个 0f 到 1f 的进度值,然后通过 PathMeasure 截取路径的一部分来绘制。
@Composable
fun PathAnimation() {
val progress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 2000, easing = LinearEasing)
)
}
val path = remember {
Path().apply {
moveTo(50f, 150f)
cubicTo(50f, 50f, 150f, 50f, 150f, 150f)
}
}
val pathMeasure = remember { PathMeasure(path, false) }
val pathLength = pathMeasure.length
Canvas(modifier = Modifier.size(200.dp)) {
val currentProgress = progress.value
val segmentPath = Path()
// 从路径起点截取到当前进度位置
pathMeasure.getSegment(
startDistance = 0f,
stopDistance = pathLength * currentProgress,
destination = segmentPath,
startWithMoveTo = true
)
drawPath(
path = segmentPath,
color = Color(0xFF6200EE),
style = Stroke(
width = 6.dp.toPx(),
cap = StrokeCap.Round
)
)
}
}
这段代码做了三件事:
- 用
Animatable驱动一个 0→1 的动画 - 用
PathMeasure测量路径总长度 - 每帧根据进度截取路径的一部分来绘制
效果就是:一条曲线从起点开始「生长」到终点。很像你用手指在屏幕上画线,线会慢慢出现。
PathMeasure 和 path 用 remember 缓存起来。否则每帧重建对象,性能会崩。我刚开始没注意,动画一跑就掉帧。
知识体系总览
下面这张图总结了 Canvas 绘图的整体脉络:
总结一下
Canvas 绘图的核心就四个 API:
| API | 用途 | 关键参数 |
|---|---|---|
| drawLine | 画直线 | start, end, strokeWidth, cap |
| drawCircle | 画圆 | center, radius, style |
| drawRect | 画矩形 | topLeft, size, cornerRadius |
| drawPath | 画任意形状 | path, style, PathMeasure |
我个人觉得,Path 动画是这里面最有意思的部分。它把「静态绘图」和「动态交互」连在了一起。你想想看,一个 App 里那些流畅的加载动画、手势轨迹、图表绘制... 底层都是 Path + 动画的组合。
嗯,这一节的内容就到这里。代码不多,但每个 API 都值得你亲手敲一遍。尤其是 Path 动画,多调调参数,感受一下 PathMeasure.getSegment() 的截取逻辑。下次遇到自定义绘图需求,你就知道该从哪里下手了。
公众号:蓝海资料掘金营,微信deep3321