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,它提供了所有绘制方法,比如 drawLinedrawCircledrawPath 等等。

我刚开始用的时候犯过一个低级错误——没给 Canvas 设置尺寸。结果画了半天,屏幕上啥也没有。后来才反应过来,Canvas 默认大小是 0,你必须通过 Modifier 给它指定宽高,比如 Modifier.size(200.dp)

小提示:Canvas 的坐标系和传统 View 一样,左上角是 (0,0),x 轴向右,y 轴向下。单位是像素,但 Compose 里你通常用 dp 或 px 来指定。

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())
)
重点:drawCircle 的 radius 是 Float 类型,记得用 .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 还支持贝塞尔曲线,比如 quadraticBezierTocubicBezierTo。我在项目中画过波浪动画,就是用 cubicBezierTo 生成平滑的曲线路径,然后配合动画不断偏移。

注意:Path 的坐标是相对于 Canvas 本身的,不是相对于屏幕。如果你在 Canvas 里嵌套了其他布局,记得考虑偏移量。我曾经踩过这个坑,画出来的图形位置总是不对,后来才发现是没考虑父布局的 padding。

自定义绘制组件

光画几个基本图形还不够,实际项目中我们往往需要封装成可复用的组件。比如做一个自定义的「温度计」组件:

@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 绘图 Canvas 组件基础 Modifier 控制尺寸 | DrawScope 上下文 核心绘制 API drawLine drawCircle drawPath (贝塞尔曲线) 自定义绘制组件 封装可复用 Composable 参数驱动动态绘制 动画配合 animateFloatAsState 核心原则:坐标理解 + 尺寸转换 + 路径组合 dp.toPx() | size 属性 | Path 闭合与填充

避坑指南

最后分享几个我实际踩过的坑:

  • 坐标单位混淆:Canvas 内部默认使用像素,但 Compose 的布局参数通常是 dp。记得用 dp.toPx() 转换,否则在不同密度屏幕上会变形。
  • Path 不闭合:如果你用 Stroke 画路径,最后一段不会自动连回起点。需要手动调用 close() 或者画一条线回去。
  • 性能问题:不要在 Canvas 的 onDraw 里创建大量对象(比如 Path),每次重组都会重新创建。把 Path 定义在 Composable 外部或者用 remember 缓存起来。
  • 动画重绘:如果你用 animateFloatAsState 驱动 Canvas 绘制,记得把动画值作为参数传进去,Compose 会自动触发重组。不要手动调用 invalidate(),那是传统 View 的做法。

嗯,Canvas 这块内容其实不难,关键是多动手画一画。你可以在一个 Composable 里同时画线、圆、路径,组合出各种有趣的图形。下次遇到自定义 UI 需求,别急着找第三方库,试试自己用 Canvas 画一个,说不定更有成就感。


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