摘要
当现成组件拼不出设计图上的圆环进度条、渐变折线图和不规则标签时,就该轮到 Compose 的自定义绘制登场了。本文从 DrawScope 的四个绘制入口讲起,深入 Path 的贝塞尔曲线、弧线与布尔运算,Brush 的多色渐变、BlendMode 混合与自定义 Shader,再到自定义 Shape 与动画驱动绘制,最后用一个"会呼吸"的数据看板把知识串成实战,并附上常见陷阱速查与"形状—颜色—动画"三层协调思维模型。核心原则只有一句:计算提前,绘制轻量。
目录
- 摘要
- 前言
- 一、先看一个“组件拼不出来”的现场
- 二、复习与进阶:DrawScope 的四个层次
- 2.1 四个绘制入口,选对省一半性能
- 2.2 坐标系与单位
- 三、Path 进阶:贝塞尔曲线、弧线与布尔运算
- 3.1 贝塞尔曲线:画出“自然”的曲线
- 3.2 弧线:drawArc 与 Path.arcTo
- 3.3 布尔运算:Path.op
- 四、Brush 进阶:渐变、Shader 与混合模式
- 4.1 三种基础渐变
- 4.2 扫描渐变做“环形进度条”
- 4.3 BlendMode:让图层“反应”
- 4.4 自定义 Shader
- 五、自定义 Shape:让 Modifier.clip 和背景“长成任意形状”
- 5.1 什么是 Shape
- 5.2 Shape + 动画
- 六、动画驱动绘制:让画面“活”起来
- 6.1 rememberInfiniteTransition
- 6.2 用 animateFloatAsState 做进度动画
- 6.3 性能要点
- 七、综合实战:一个“会呼吸”的数据看板
- 7.1 圆环进度条
- 7.2 折线图
- 7.3 自定义标签 Shape
- 八、常见陷阱速查
- 九、思维模型:绘制的“三层协调”
- 十、小结与下一课预告
前言
写 Compose 的 UI,大部分时候我们都在“拼组件”:一个 Column 套几个 Row,Text 配 Button,LazyColumn 装列表。这套打法能覆盖日常九成的需求,也足够快。
但总有一些时刻,你会卡住。
比如设计稿上那个圆环进度条,两端是圆角的,颜色从蓝渐变到紫,中间还要嵌一个百分比数字。你把 CircularProgressIndicator 翻了个遍,发现它不支持渐变,也不支持圆角端点。再比如那张折线图,线下要有半透明的面积填充,拐点要有小白点,线条还得是平滑的曲线而不是生硬的折线。又比如一排标签,每个都是切了角的多边形,RoundedCornerShape 怎么调都对不上。
这时候你会发现:组件是有限的,而设计稿是无限的。
Compose 给了我们两套工具。一套是“乐高积木”——现成组件,拼装快、上手易,但形状被限定在积木的模具里。另一套是“3D 打印机”——Canvas 和 DrawScope,你想画什么就画什么,代价是你得自己算坐标、自己控像素、自己管性能。
这一课,讲的就是第二套工具。
我们不会一上来就丢一堆 API。而是从“组件拼不出来”的真实场景出发,先复习 DrawScope 的四个绘制入口,把地基打牢;再一层层往上走:用 Path 画出自然的曲线和复杂的形状,用 Brush 上色、用 BlendMode 让图层产生反应,用自定义 Shape 让组件“长成任意形状”,最后用动画让画面真正活起来。每一块知识都配了可以直接跑的代码,也配了“为什么这么写”的直觉解释。
学完之后,你会拥有一种新的能力:看到一张设计图,不再先想“哪个组件能凑出来”,而是想“我该怎么把它画出来”。
如果你已经会用 Canvas 画个圆、画条线,那这一课正好帮你跨过“能画”到“能画好”的那道坎。如果你还没怎么碰过自定义绘制,也别担心——我们会从最基础的坐标系讲起,一步一步来。
准备好了吗?我们先从一个“组件拼不出来”的现场开始。
一、先看一个“组件拼不出来”的现场
第38课我们把动画和手势的协调讲透了。共享元素飞过去、嵌套滚动商量着滚、手势冲突按方向裁决——交互层面的问题基本都有解了。
但有一类需求,用现成组件怎么拼都拼不出来。
产品经理拿着一张设计图过来:“这个数据看板,要一个圆环进度条,中间显示百分比,圆环两端是圆角的,进度颜色从蓝到紫渐变。旁边再来一个折线图,线下面积有半透明填充,拐点有小白点。下面还有个标签云,每个标签是不规则的多边形。”
你看了看 CircularProgressIndicator——它不支持渐变,不支持圆角端点,不支持中间嵌文字。你看了看 Canvas——好像能画,但 drawArc 的渐变怎么搞?drawPath 的贝塞尔曲线控制点怎么算?混合模式又是什么?
这就是自定义绘制的战场。 现成组件是“乐高积木”,拼不出所有形状;Canvas 是“3D打印机”,你想画什么就画什么,代价是你得自己算坐标、自己控像素。
这一课,我们就把 Compose 的自定义绘制从“能画个圆”提升到“能画复杂图表和视觉效果”。
下面是本课的整体知识地图,先建立全局视角:
#mermaid-svg-CxIbAZUgZrcz0TcA{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CxIbAZUgZrcz0TcA .error-icon{fill:#552222;}#mermaid-svg-CxIbAZUgZrcz0TcA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CxIbAZUgZrcz0TcA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CxIbAZUgZrcz0TcA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CxIbAZUgZrcz0TcA .marker.cross{stroke:#333333;}#mermaid-svg-CxIbAZUgZrcz0TcA svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CxIbAZUgZrcz0TcA p{margin:0;}#mermaid-svg-CxIbAZUgZrcz0TcA .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster-label text{fill:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster-label span{color:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster-label span p{background-color:transparent;}#mermaid-svg-CxIbAZUgZrcz0TcA .label text,#mermaid-svg-CxIbAZUgZrcz0TcA span{fill:#333;color:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA .node rect,#mermaid-svg-CxIbAZUgZrcz0TcA .node circle,#mermaid-svg-CxIbAZUgZrcz0TcA .node ellipse,#mermaid-svg-CxIbAZUgZrcz0TcA .node polygon,#mermaid-svg-CxIbAZUgZrcz0TcA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CxIbAZUgZrcz0TcA .rough-node .label text,#mermaid-svg-CxIbAZUgZrcz0TcA .node .label text,#mermaid-svg-CxIbAZUgZrcz0TcA .image-shape .label,#mermaid-svg-CxIbAZUgZrcz0TcA .icon-shape .label{text-anchor:middle;}#mermaid-svg-CxIbAZUgZrcz0TcA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CxIbAZUgZrcz0TcA .rough-node .label,#mermaid-svg-CxIbAZUgZrcz0TcA .node .label,#mermaid-svg-CxIbAZUgZrcz0TcA .image-shape .label,#mermaid-svg-CxIbAZUgZrcz0TcA .icon-shape .label{text-align:center;}#mermaid-svg-CxIbAZUgZrcz0TcA .node.clickable{cursor:pointer;}#mermaid-svg-CxIbAZUgZrcz0TcA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CxIbAZUgZrcz0TcA .arrowheadPath{fill:#333333;}#mermaid-svg-CxIbAZUgZrcz0TcA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CxIbAZUgZrcz0TcA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CxIbAZUgZrcz0TcA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CxIbAZUgZrcz0TcA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CxIbAZUgZrcz0TcA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CxIbAZUgZrcz0TcA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster text{fill:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA .cluster span{color:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CxIbAZUgZrcz0TcA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CxIbAZUgZrcz0TcA rect.text{fill:none;stroke-width:0;}#mermaid-svg-CxIbAZUgZrcz0TcA .icon-shape,#mermaid-svg-CxIbAZUgZrcz0TcA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CxIbAZUgZrcz0TcA .icon-shape p,#mermaid-svg-CxIbAZUgZrcz0TcA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CxIbAZUgZrcz0TcA .icon-shape .label rect,#mermaid-svg-CxIbAZUgZrcz0TcA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CxIbAZUgZrcz0TcA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CxIbAZUgZrcz0TcA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CxIbAZUgZrcz0TcA :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
现成组件拼不出来
自定义绘制
Path 进阶:形状
Brush 进阶:颜色
自定义 Shape:可见区域
动画驱动绘制
贝塞尔曲线
弧线 drawArc / arcTo
布尔运算 Path.op
三种渐变
BlendMode 混合
自定义 Shader
createOutline 返回 Outline.Generic
rememberInfiniteTransition
animateFloatAsState
综合实战:数据看板
常见陷阱速查
思维模型:三层协调
二、复习与进阶:DrawScope 的四个层次
第8课我们讲过 Canvas 和 DrawScope 的基础。这里先快速复习,然后直接进入进阶。
2.1 四个绘制入口,选对省一半性能
| Canvas(modifier) { } | 纯绘制,不需要子内容 | 好 |
| Modifier.drawBehind { } | 在内容后面画背景 | 好 |
| Modifier.drawWithContent { } | 在内容前后都能画 | 好 |
| Modifier.drawWithCache { } | 绘制依赖尺寸但不依赖状态,需要缓存 | 最好 |
进阶要点:drawWithCache 的 onDrawBehind 里可以复用 Path、Brush、Shader 等对象,避免每帧重新创建。对于复杂图形,这是性能分水岭。
Modifier.drawWithCache {
// 只在尺寸变化时执行一次
val path = buildComplexPath(size)
val brush = Brush.linearGradient(colors, start = Offset.Zero, end = size.toOffset())
onDrawBehind {
// 每帧执行,但直接复用 path 和 brush
drawPath(path, brush)
}
}
四个绘制入口的选型流程如下:
#mermaid-svg-Bp24HnFI6t9k401g{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Bp24HnFI6t9k401g .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Bp24HnFI6t9k401g .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Bp24HnFI6t9k401g .error-icon{fill:#552222;}#mermaid-svg-Bp24HnFI6t9k401g .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Bp24HnFI6t9k401g .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Bp24HnFI6t9k401g .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Bp24HnFI6t9k401g .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Bp24HnFI6t9k401g .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Bp24HnFI6t9k401g .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Bp24HnFI6t9k401g .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Bp24HnFI6t9k401g .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Bp24HnFI6t9k401g .marker.cross{stroke:#333333;}#mermaid-svg-Bp24HnFI6t9k401g svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Bp24HnFI6t9k401g p{margin:0;}#mermaid-svg-Bp24HnFI6t9k401g .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Bp24HnFI6t9k401g .cluster-label text{fill:#333;}#mermaid-svg-Bp24HnFI6t9k401g .cluster-label span{color:#333;}#mermaid-svg-Bp24HnFI6t9k401g .cluster-label span p{background-color:transparent;}#mermaid-svg-Bp24HnFI6t9k401g .label text,#mermaid-svg-Bp24HnFI6t9k401g span{fill:#333;color:#333;}#mermaid-svg-Bp24HnFI6t9k401g .node rect,#mermaid-svg-Bp24HnFI6t9k401g .node circle,#mermaid-svg-Bp24HnFI6t9k401g .node ellipse,#mermaid-svg-Bp24HnFI6t9k401g .node polygon,#mermaid-svg-Bp24HnFI6t9k401g .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Bp24HnFI6t9k401g .rough-node .label text,#mermaid-svg-Bp24HnFI6t9k401g .node .label text,#mermaid-svg-Bp24HnFI6t9k401g .image-shape .label,#mermaid-svg-Bp24HnFI6t9k401g .icon-shape .label{text-anchor:middle;}#mermaid-svg-Bp24HnFI6t9k401g .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Bp24HnFI6t9k401g .rough-node .label,#mermaid-svg-Bp24HnFI6t9k401g .node .label,#mermaid-svg-Bp24HnFI6t9k401g .image-shape .label,#mermaid-svg-Bp24HnFI6t9k401g .icon-shape .label{text-align:center;}#mermaid-svg-Bp24HnFI6t9k401g .node.clickable{cursor:pointer;}#mermaid-svg-Bp24HnFI6t9k401g .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Bp24HnFI6t9k401g .arrowheadPath{fill:#333333;}#mermaid-svg-Bp24HnFI6t9k401g .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Bp24HnFI6t9k401g .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Bp24HnFI6t9k401g .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bp24HnFI6t9k401g .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Bp24HnFI6t9k401g .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bp24HnFI6t9k401g .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Bp24HnFI6t9k401g .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Bp24HnFI6t9k401g .cluster text{fill:#333;}#mermaid-svg-Bp24HnFI6t9k401g .cluster span{color:#333;}#mermaid-svg-Bp24HnFI6t9k401g div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Bp24HnFI6t9k401g .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Bp24HnFI6t9k401g rect.text{fill:none;stroke-width:0;}#mermaid-svg-Bp24HnFI6t9k401g .icon-shape,#mermaid-svg-Bp24HnFI6t9k401g .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bp24HnFI6t9k401g .icon-shape p,#mermaid-svg-Bp24HnFI6t9k401g .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Bp24HnFI6t9k401g .icon-shape .label rect,#mermaid-svg-Bp24HnFI6t9k401g .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bp24HnFI6t9k401g .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Bp24HnFI6t9k401g .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Bp24HnFI6t9k401g :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
是
否
是
否
需要绘制内容
需要子内容吗?
Canvas(modifier) { }
绘制依赖尺寸但不依赖状态?
Modifier.drawWithCache { }
在内容前后都要画?
Modifier.drawWithContent { }
Modifier.drawBehind { }
onDrawBehind 里复用 Path / Brush / Shader
2.2 坐标系与单位
DrawScope 的坐标系:原点在左上角,x 向右为正,y 向下为正,单位是像素。
DrawScope 实现了 Density 接口,可以直接 16.dp.toPx()。size 是画布尺寸,center 是画布中心。
三、Path 进阶:贝塞尔曲线、弧线与布尔运算
Path 是自定义绘制的核心。简单的 moveTo/lineTo 第8课讲过,这里讲三个进阶能力。
3.1 贝塞尔曲线:画出“自然”的曲线
quadraticBezierTo 是二次贝塞尔,一个控制点;cubicTo 是三次贝塞尔,两个控制点。
val path = Path().apply {
moveTo(0f, size.height * 0.7f)
cubicTo(
size.width * 0.25f, size.height * 0.1f, // 控制点1
size.width * 0.75f, size.height * 0.9f, // 控制点2
size.width, size.height * 0.3f // 终点
)
}
drawPath(path, color = Color.Blue, style = Stroke(width = 4.dp.toPx()))
控制点的直觉:三次贝塞尔曲线在起点处“朝向”控制点1,在终点处“来自”控制点2。想让曲线在起点水平出发,就把控制点1放在起点的水平方向上。
3.2 弧线:drawArc 与 Path.arcTo
drawArc 直接画弧,Path.arcTo 把弧拼进路径。
// 直接画弧
drawArc(
color = Color.Red,
startAngle = –90f, // 从正上方开始
sweepAngle = 270f, // 顺时针扫 270 度
useCenter = false,
style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round),
topLeft = Offset(20f, 20f),
size = Size(size.width – 40f, size.height – 40f)
)
// 拼进路径
val path = Path().apply {
arcTo(
rect = Rect(20f, 20f, size.width – 20f, size.height – 20f),
startAngleDegrees = –90f,
sweepAngleDegrees = 180f,
forceMoveTo = true
)
}
角度规则:0 度在正右方(3 点钟方向),顺时针增加。-90f 是正上方(12 点钟方向)。
3.3 布尔运算:Path.op
这是很多人不知道的能力。两个 Path 可以求交集、并集、差集、异或:
val circle = Path().apply { addOval(Rect(center, 100f)) }
val rect = Path().apply { addRect(Rect(Offset(50f, 50f), Size(200f, 200f))) }
val result = Path().apply {
op(circle, rect, PathOperation.Intersect)
}
drawPath(result, Color.Magenta)
PathOperation 的取值:
| Union | 并集 |
| Intersect | 交集 |
| Difference | 差集(A 减 B) |
| ReverseDifference | 差集(B 减 A) |
| Xor | 异或(不重叠的部分) |
应用场景:做“挖空”效果(圆减去文字)、复杂图标、不规则形状的遮罩。
Path.op 布尔运算的四种结果示意:
#mermaid-svg-FmccgfaZDromUO3S{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FmccgfaZDromUO3S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FmccgfaZDromUO3S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FmccgfaZDromUO3S .error-icon{fill:#552222;}#mermaid-svg-FmccgfaZDromUO3S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FmccgfaZDromUO3S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FmccgfaZDromUO3S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FmccgfaZDromUO3S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FmccgfaZDromUO3S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FmccgfaZDromUO3S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FmccgfaZDromUO3S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FmccgfaZDromUO3S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FmccgfaZDromUO3S .marker.cross{stroke:#333333;}#mermaid-svg-FmccgfaZDromUO3S svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FmccgfaZDromUO3S p{margin:0;}#mermaid-svg-FmccgfaZDromUO3S .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-FmccgfaZDromUO3S .cluster-label text{fill:#333;}#mermaid-svg-FmccgfaZDromUO3S .cluster-label span{color:#333;}#mermaid-svg-FmccgfaZDromUO3S .cluster-label span p{background-color:transparent;}#mermaid-svg-FmccgfaZDromUO3S .label text,#mermaid-svg-FmccgfaZDromUO3S span{fill:#333;color:#333;}#mermaid-svg-FmccgfaZDromUO3S .node rect,#mermaid-svg-FmccgfaZDromUO3S .node circle,#mermaid-svg-FmccgfaZDromUO3S .node ellipse,#mermaid-svg-FmccgfaZDromUO3S .node polygon,#mermaid-svg-FmccgfaZDromUO3S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FmccgfaZDromUO3S .rough-node .label text,#mermaid-svg-FmccgfaZDromUO3S .node .label text,#mermaid-svg-FmccgfaZDromUO3S .image-shape .label,#mermaid-svg-FmccgfaZDromUO3S .icon-shape .label{text-anchor:middle;}#mermaid-svg-FmccgfaZDromUO3S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FmccgfaZDromUO3S .rough-node .label,#mermaid-svg-FmccgfaZDromUO3S .node .label,#mermaid-svg-FmccgfaZDromUO3S .image-shape .label,#mermaid-svg-FmccgfaZDromUO3S .icon-shape .label{text-align:center;}#mermaid-svg-FmccgfaZDromUO3S .node.clickable{cursor:pointer;}#mermaid-svg-FmccgfaZDromUO3S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FmccgfaZDromUO3S .arrowheadPath{fill:#333333;}#mermaid-svg-FmccgfaZDromUO3S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FmccgfaZDromUO3S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FmccgfaZDromUO3S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FmccgfaZDromUO3S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FmccgfaZDromUO3S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FmccgfaZDromUO3S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FmccgfaZDromUO3S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FmccgfaZDromUO3S .cluster text{fill:#333;}#mermaid-svg-FmccgfaZDromUO3S .cluster span{color:#333;}#mermaid-svg-FmccgfaZDromUO3S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FmccgfaZDromUO3S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FmccgfaZDromUO3S rect.text{fill:none;stroke-width:0;}#mermaid-svg-FmccgfaZDromUO3S .icon-shape,#mermaid-svg-FmccgfaZDromUO3S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FmccgfaZDromUO3S .icon-shape p,#mermaid-svg-FmccgfaZDromUO3S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FmccgfaZDromUO3S .icon-shape .label rect,#mermaid-svg-FmccgfaZDromUO3S .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FmccgfaZDromUO3S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FmccgfaZDromUO3S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FmccgfaZDromUO3S :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
输入
圆 Path
矩形 Path
Path.op 布尔运算
Union 并集
Intersect 交集
Difference 差集 A-B
Xor 异或
四、Brush 进阶:渐变、Shader 与混合模式
4.1 三种基础渐变
第8课讲过 linearGradient、radialGradient、sweepGradient。进阶用法是多色停靠点和TileMode。
Brush.linearGradient(
colorStops = arrayOf(
0.0f to Color.Blue,
0.3f to Color.Cyan,
0.7f to Color.Magenta,
1.0f to Color.Red
),
start = Offset.Zero,
end = Offset(size.width, size.height),
tileMode = TileMode.Clamp // 超出范围时钳制边缘颜色
)
TileMode 的取值:Clamp(钳制)、Repeated(重复)、Mirror(镜像)、Decal(只画原始区域)。
4.2 扫描渐变做“环形进度条”
val brush = Brush.sweepGradient(
0.0f to Color(0xFF2196F3),
0.5f to Color(0xFF9C27B0),
1.0f to Color(0xFF2196F3)
)
drawCircle(brush = brush, radius = radius, style = Stroke(width = 16.dp.toPx()))
扫描渐变从正右方开始顺时针扫一圈。做环形进度条时,用 drawArc 配合 sweepGradient,只画需要的那一段。
4.3 BlendMode:让图层“反应”
BlendMode 控制新绘制的内容如何与已有内容混合。第8课提过,这里讲几个进阶用法。
// 正片叠底:颜色变深
drawCircle(color = Color.Blue, blendMode = BlendMode.Multiply)
// 滤色:颜色变浅
drawCircle(color = Color.Blue, blendMode = BlendMode.Screen)
// 叠加:增强对比
drawCircle(color = Color.Blue, blendMode = BlendMode.Overlay)
// 挖空:用 DstOut 把已有内容“擦掉”
drawCircle(color = Color.Black, blendMode = BlendMode.DstOut)
“探照灯”效果:先画一层半透明黑遮罩,再用 DstOut 在鼠标/手指位置画一个径向渐变圆,把遮罩“擦掉”,露出下面的内容。
drawRect(Color.Black.copy(alpha = 0.7f))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.Transparent, Color.Black),
center = fingerPosition,
radius = 200f
),
center = fingerPosition,
radius = 200f,
blendMode = BlendMode.DstOut
)
BlendMode 的混合逻辑可以用下面的流程理解:
#mermaid-svg-xbwaFJQLvPGCq2rq{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xbwaFJQLvPGCq2rq .error-icon{fill:#552222;}#mermaid-svg-xbwaFJQLvPGCq2rq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xbwaFJQLvPGCq2rq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xbwaFJQLvPGCq2rq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xbwaFJQLvPGCq2rq .marker.cross{stroke:#333333;}#mermaid-svg-xbwaFJQLvPGCq2rq svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xbwaFJQLvPGCq2rq p{margin:0;}#mermaid-svg-xbwaFJQLvPGCq2rq .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster-label text{fill:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster-label span{color:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster-label span p{background-color:transparent;}#mermaid-svg-xbwaFJQLvPGCq2rq .label text,#mermaid-svg-xbwaFJQLvPGCq2rq span{fill:#333;color:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq .node rect,#mermaid-svg-xbwaFJQLvPGCq2rq .node circle,#mermaid-svg-xbwaFJQLvPGCq2rq .node ellipse,#mermaid-svg-xbwaFJQLvPGCq2rq .node polygon,#mermaid-svg-xbwaFJQLvPGCq2rq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xbwaFJQLvPGCq2rq .rough-node .label text,#mermaid-svg-xbwaFJQLvPGCq2rq .node .label text,#mermaid-svg-xbwaFJQLvPGCq2rq .image-shape .label,#mermaid-svg-xbwaFJQLvPGCq2rq .icon-shape .label{text-anchor:middle;}#mermaid-svg-xbwaFJQLvPGCq2rq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xbwaFJQLvPGCq2rq .rough-node .label,#mermaid-svg-xbwaFJQLvPGCq2rq .node .label,#mermaid-svg-xbwaFJQLvPGCq2rq .image-shape .label,#mermaid-svg-xbwaFJQLvPGCq2rq .icon-shape .label{text-align:center;}#mermaid-svg-xbwaFJQLvPGCq2rq .node.clickable{cursor:pointer;}#mermaid-svg-xbwaFJQLvPGCq2rq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xbwaFJQLvPGCq2rq .arrowheadPath{fill:#333333;}#mermaid-svg-xbwaFJQLvPGCq2rq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xbwaFJQLvPGCq2rq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xbwaFJQLvPGCq2rq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xbwaFJQLvPGCq2rq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xbwaFJQLvPGCq2rq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xbwaFJQLvPGCq2rq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster text{fill:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq .cluster span{color:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xbwaFJQLvPGCq2rq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xbwaFJQLvPGCq2rq rect.text{fill:none;stroke-width:0;}#mermaid-svg-xbwaFJQLvPGCq2rq .icon-shape,#mermaid-svg-xbwaFJQLvPGCq2rq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xbwaFJQLvPGCq2rq .icon-shape p,#mermaid-svg-xbwaFJQLvPGCq2rq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xbwaFJQLvPGCq2rq .icon-shape .label rect,#mermaid-svg-xbwaFJQLvPGCq2rq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xbwaFJQLvPGCq2rq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xbwaFJQLvPGCq2rq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xbwaFJQLvPGCq2rq :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
已有内容(底层)
BlendMode 混合
新绘制内容(上层)
Multiply 正片叠底:变深
Screen 滤色:变浅
Overlay 叠加:增强对比
DstOut 挖空:擦除底层
4.4 自定义 Shader
Brush 本质上是 Shader 的封装。如果需要更底层的控制,可以直接创建 Shader:
val shader = LinearGradientShader(
from = Offset.Zero,
to = Offset(size.width, 0f),
colors = listOf(Color.Red, Color.Blue),
tileMode = TileMode.Clamp
)
drawRect(brush = ShaderBrush(shader))
大多数场景用 Brush 就够了,ShaderBrush 是给需要精细控制的场景准备的。
五、自定义 Shape:让 Modifier.clip 和背景“长成任意形状”
5.1 什么是 Shape
Shape 是一个接口,只有一个方法:createOutline(size, layoutDirection, density)。它返回一个 Outline,描述“这个组件的可见区域是什么形状”。
Compose 内置了 RoundedCornerShape、CircleShape、CutCornerShape。但你可以自定义:
class WaveShape(
private val amplitude: Dp = 20.dp,
private val frequency: Float = 2f
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val amp = with(density) { amplitude.toPx() }
val path = Path().apply {
moveTo(0f, size.height – amp)
val step = size.width / 100
for (x in 0..100) {
val y = size.height – amp +
sin(x / 100f * frequency * 2 * PI).toFloat() * amp
lineTo(x * step, y)
}
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
return Outline.Generic(path)
}
}
使用:
Box(
modifier = Modifier
.size(300.dp, 200.dp)
.clip(WaveShape())
.background(Color.Blue)
)
Outline 的三种类型:
| Outline.Rectangle | 矩形 |
| Outline.Rounded | 圆角矩形 |
| Outline.Generic | 任意路径 |
自定义 Shape 返回 Outline.Generic(path),Modifier.clip 会用这个路径裁剪内容,Modifier.background 会用这个路径填充背景,Modifier.border 会沿着这个路径描边。
5.2 Shape + 动画
Shape 可以和 animate*AsState 配合,做出“形状变形”动画:
val cornerRadius by animateDpAsState(
targetValue = if (expanded) 48.dp else 8.dp,
label = "corner"
)
Box(
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(cornerRadius))
.background(Color.Magenta)
)
RoundedCornerShape 本身支持 Dp 参数,可以直接动画。自定义 Shape 的动画需要自己插值路径参数。
六、动画驱动绘制:让画面“活”起来
Canvas 的 lambda 在每一帧动画中都会重新执行。只要绘制逻辑依赖动画状态,画面就会动。
6.1 rememberInfiniteTransition
val infiniteTransition = rememberInfiniteTransition(label = "wave")
val phase by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 2 * PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing)
),
label = "phase"
)
Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) {
val path = Path().apply {
moveTo(0f, size.height / 2)
for (x in 0..size.width.toInt() step 4) {
val y = size.height / 2 + sin(x / 80f + phase) * 30f
lineTo(x.toFloat(), y)
}
}
drawPath(path, Color.Blue, style = Stroke(width = 4.dp.toPx()))
}
phase 从 0 变到 2π,正弦波就“流动”起来。
6.2 用 animateFloatAsState 做进度动画
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(1000, easing = FastOutSlowInEasing),
label = "progress"
)
Canvas(modifier = Modifier.size(200.dp)) {
drawArc(
color = Color.Blue,
startAngle = –90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
style = Stroke(width = 16.dp.toPx(), cap = StrokeCap.Round)
)
}
6.3 性能要点
不要在绘制 lambda 里创建新对象。Offset、Path、Brush、Color 都应该提前创建或用 drawWithCache 缓存。每帧创建对象会触发 GC,导致掉帧。
// 不推荐:每帧创建 Path
Canvas(modifier) {
val path = Path().apply { /* … */ } // 每帧新建
drawPath(path, Color.Red)
}
// 推荐:用 drawWithCache 缓存
Modifier.drawWithCache {
val path = buildPath(size) // 只在尺寸变化时创建
onDrawBehind { drawPath(path, Color.Red) }
}
七、综合实战:一个“会呼吸”的数据看板
把这一课的知识串起来:圆环进度条(渐变+圆角端点+动画)、折线图(贝塞尔曲线+面积填充+拐点)、标签云(自定义Shape)。
7.1 圆环进度条
@Composable
fun GradientRingProgress(
progress: Float,
modifier: Modifier = Modifier,
size: Dp = 160.dp,
strokeWidth: Dp = 16.dp
) {
val animatedProgress by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(1000, easing = FastOutSlowInEasing),
label = "ring_progress"
)
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val strokePx = strokeWidth.toPx()
val radius = (this.size.minDimension – strokePx) / 2
val topLeft = Offset(
(this.size.width – radius * 2) / 2,
(this.size.height – radius * 2) / 2
)
val arcSize = Size(radius * 2, radius * 2)
// 背景环
drawArc(
color = Color(0xFFE0E0E0),
startAngle = –90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = strokePx),
topLeft = topLeft,
size = arcSize
)
// 进度环:扫描渐变 + 圆角端点
val brush = Brush.sweepGradient(
0.0f to Color(0xFF2196F3),
0.5f to Color(0xFF9C27B0),
1.0f to Color(0xFF2196F3)
)
drawArc(
brush = brush,
startAngle = –90f,
sweepAngle = 360f * animatedProgress,
useCenter = false,
style = Stroke(width = strokePx, cap = StrokeCap.Round),
topLeft = topLeft,
size = arcSize
)
}
Text(
text = "${(animatedProgress * 100).toInt()}%",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
}
}
设计要点:Brush.sweepGradient 配合 drawArc 做出渐变圆环;StrokeCap.Round 让弧的两端圆润;animateFloatAsState 让进度平滑过渡。
7.2 折线图
@Composable
fun LineChart(
data: List<Float>,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier) {
if (data.size < 2) return@Canvas
val maxValue = data.max().coerceAtLeast(1f)
val minValue = data.min().coerceAtMost(0f)
val range = (maxValue – minValue).coerceAtLeast(1f)
val stepX = size.width / (data.size – 1)
val padding = 24f
val points = data.mapIndexed { i, v ->
Offset(
x = i * stepX,
y = padding + (size.height – padding * 2) *
(1 – (v – minValue) / range)
)
}
// 面积填充
val fillPath = Path().apply {
moveTo(points.first().x, size.height)
points.forEach { lineTo(it.x, it.y) }
lineTo(points.last().x, size.height)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(
listOf(
Color(0xFF2196F3).copy(alpha = 0.4f),
Color.Transparent
)
)
)
// 折线:用三次贝塞尔做平滑
val linePath = Path().apply {
moveTo(points.first().x, points.first().y)
for (i in 1 until points.size) {
val prev = points[i – 1]
val curr = points[i]
val midX = (prev.x + curr.x) / 2
cubicTo(
midX, prev.y,
midX, curr.y,
curr.x, curr.y
)
}
}
drawPath(
path = linePath,
color = Color(0xFF2196F3),
style = Stroke(
width = 3.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
// 拐点
points.forEach { point ->
drawCircle(Color.White, radius = 6.dp.toPx(), center = point)
drawCircle(
color = Color(0xFF2196F3),
radius = 6.dp.toPx(),
center = point,
style = Stroke(width = 2.dp.toPx())
)
}
}
}
设计要点:面积填充用 verticalGradient 从半透明到透明;折线用 cubicTo 平滑,控制点取前后点的中点,避免过冲;拐点用白底蓝边的小圆。
7.3 自定义标签 Shape
class TagShape(
private val cutSize: Dp = 8.dp
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val cut = with(density) { cutSize.toPx() }
val path = Path().apply {
moveTo(cut, 0f)
lineTo(size.width – cut, 0f)
lineTo(size.width, cut)
lineTo(size.width, size.height – cut)
lineTo(size.width – cut, size.height)
lineTo(cut, size.height)
lineTo(0f, size.height – cut)
lineTo(0f, cut)
close()
}
return Outline.Generic(path)
}
}
@Composable
fun Tag(text: String) {
Text(
text = text,
modifier = Modifier
.clip(TagShape())
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 12.dp, vertical = 6.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.labelMedium
)
}
设计要点:切角标签是“八边形”,每个角切掉一个小三角形。Path 的八个顶点顺序描述清楚即可。Outline.Generic 让 clip 和 background 都沿着这个形状生效。
八、常见陷阱速查
| 绘制中每帧创建 Path | Canvas 里 Path().apply { } | GC 频繁,掉帧 | 用 drawWithCache 缓存 |
| dp/px 混用 | strokeWidth = 4f 当成 dp | 线太细或太粗 | 用 4.dp.toPx() |
| 坐标算错 | 圆心用了 (width/2, height/2) 但画布不是正方形 | 圆偏了 | 用 size.minDimension 算半径 |
| 渐变方向反了 | linearGradient 的 start/end 写反 | 颜色方向不对 | 明确起点和终点 |
| Path.op 的路径没闭合 | 布尔运算结果异常 | 形状不对 | 运算前先 close() |
| 自定义 Shape 返回 Outline.Rectangle | 忘了返回 Generic(path) | 裁剪无效 | 用 Outline.Generic(path) |
| 动画值在组合阶段读 | Modifier.offset(x = animatedX.dp) | 每帧重组 | 用 Modifier.offset { } lambda |
| drawArc 的 topLeft/size 没算对 | 弧画到画布外面 | 只看到一部分 | 用 radius 反推 topLeft 和 size |
| 贝塞尔控制点乱放 | 曲线过冲或打结 | 视觉不自然 | 控制点取前后点的中点 |
| BlendMode.DstOut 顺序反了 | 挖空效果不对 | 全黑或全透明 | 先画遮罩,再用 DstOut 挖 |
九、思维模型:绘制的“三层协调”
第一层:形状。 Path 定义“画什么”,Shape 定义“组件的可见区域”。贝塞尔曲线让形状自然,布尔运算让形状复杂,自定义 Shape 让 clip/background/border 都跟着形状走。
第二层:颜色。 Brush 定义“怎么上色”。线性渐变、径向渐变、扫描渐变覆盖大多数场景。BlendMode 定义“图层怎么反应”,DstOut 做挖空,Multiply 做正片叠底。
第三层:动画。 绘制 lambda 每帧执行,动画状态驱动绘制参数,画面就动起来。rememberInfiniteTransition 做循环动画,animateFloatAsState 做状态过渡。
贯穿始终的原则:计算提前,绘制轻量。 Path、Brush、Shader 能缓存的都缓存,绘制 lambda 里只做“画”这个动作,不做“算”这个动作。像素级控制的前提是像素级性能。
绘制的“三层协调”思维模型,用一张图串起来:
#mermaid-svg-3Ff9caLXZnT9aQan{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3Ff9caLXZnT9aQan .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Ff9caLXZnT9aQan .error-icon{fill:#552222;}#mermaid-svg-3Ff9caLXZnT9aQan .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Ff9caLXZnT9aQan .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Ff9caLXZnT9aQan .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Ff9caLXZnT9aQan .marker.cross{stroke:#333333;}#mermaid-svg-3Ff9caLXZnT9aQan svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Ff9caLXZnT9aQan p{margin:0;}#mermaid-svg-3Ff9caLXZnT9aQan .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-3Ff9caLXZnT9aQan .cluster-label text{fill:#333;}#mermaid-svg-3Ff9caLXZnT9aQan .cluster-label span{color:#333;}#mermaid-svg-3Ff9caLXZnT9aQan .cluster-label span p{background-color:transparent;}#mermaid-svg-3Ff9caLXZnT9aQan .label text,#mermaid-svg-3Ff9caLXZnT9aQan span{fill:#333;color:#333;}#mermaid-svg-3Ff9caLXZnT9aQan .node rect,#mermaid-svg-3Ff9caLXZnT9aQan .node circle,#mermaid-svg-3Ff9caLXZnT9aQan .node ellipse,#mermaid-svg-3Ff9caLXZnT9aQan .node polygon,#mermaid-svg-3Ff9caLXZnT9aQan .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3Ff9caLXZnT9aQan .rough-node .label text,#mermaid-svg-3Ff9caLXZnT9aQan .node .label text,#mermaid-svg-3Ff9caLXZnT9aQan .image-shape .label,#mermaid-svg-3Ff9caLXZnT9aQan .icon-shape .label{text-anchor:middle;}#mermaid-svg-3Ff9caLXZnT9aQan .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3Ff9caLXZnT9aQan .rough-node .label,#mermaid-svg-3Ff9caLXZnT9aQan .node .label,#mermaid-svg-3Ff9caLXZnT9aQan .image-shape .label,#mermaid-svg-3Ff9caLXZnT9aQan .icon-shape .label{text-align:center;}#mermaid-svg-3Ff9caLXZnT9aQan .node.clickable{cursor:pointer;}#mermaid-svg-3Ff9caLXZnT9aQan .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3Ff9caLXZnT9aQan .arrowheadPath{fill:#333333;}#mermaid-svg-3Ff9caLXZnT9aQan .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3Ff9caLXZnT9aQan .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3Ff9caLXZnT9aQan .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Ff9caLXZnT9aQan .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3Ff9caLXZnT9aQan .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Ff9caLXZnT9aQan .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3Ff9caLXZnT9aQan .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3Ff9caLXZnT9aQan .cluster text{fill:#333;}#mermaid-svg-3Ff9caLXZnT9aQan .cluster span{color:#333;}#mermaid-svg-3Ff9caLXZnT9aQan div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3Ff9caLXZnT9aQan .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3Ff9caLXZnT9aQan rect.text{fill:none;stroke-width:0;}#mermaid-svg-3Ff9caLXZnT9aQan .icon-shape,#mermaid-svg-3Ff9caLXZnT9aQan .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3Ff9caLXZnT9aQan .icon-shape p,#mermaid-svg-3Ff9caLXZnT9aQan .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3Ff9caLXZnT9aQan .icon-shape .label rect,#mermaid-svg-3Ff9caLXZnT9aQan .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3Ff9caLXZnT9aQan .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3Ff9caLXZnT9aQan .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3Ff9caLXZnT9aQan :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
第三层:动画
绘制 lambda 每帧执行
动画状态驱动绘制参数
第二层:颜色
Brush 定义怎么上色
BlendMode 定义图层反应
第一层:形状
Path 定义画什么
Shape 定义可见区域
贯穿原则:计算提前,绘制轻量
drawWithCache 缓存 Path / Brush / Shader
十、小结与下一课预告
这一课我们把自定义绘制从“画个圆”提升到了“画复杂图表和视觉效果”。关键点回顾:
- 四个绘制入口:Canvas、drawBehind、drawWithContent、drawWithCache。复杂图形优先用 drawWithCache 缓存。
- Path 进阶:cubicTo 画贝塞尔曲线,arcTo 拼弧线,Path.op 做布尔运算(并集、交集、差集、异或)。
- Brush 进阶:多色停靠点、TileMode、扫描渐变做环形进度条。
- BlendMode:Multiply 正片叠底、Screen 滤色、DstOut 挖空。
- 自定义 Shape:实现 createOutline 返回 Outline.Generic(path),配合 clip/background/border 使用。
- 动画驱动绘制:rememberInfiniteTransition 做循环动画,animateFloatAsState 做进度动画。绘制 lambda 每帧执行,依赖动画状态就会动。
- 性能要点:不在绘制 lambda 里创建对象,用 drawWithCache 缓存 Path/Brush,用 lambda 版 Modifier.offset 避免重组。
自定义绘制是 Compose 的“像素级控制权”。 组件拼不出来的东西,Canvas 都能画。代价是你得自己算坐标、自己控性能。算对了、控好了,你就能做出别人做不出来的视觉效果。
下一课,我们会讲Compose中的自定义Layout与测量。Layout 可组合函数怎么自定义测量和放置?MeasurePolicy 怎么写?IntrinsicSize 是什么?流式布局(FlowRow/FlowColumn)怎么自己实现?这是把 Compose 从“用布局”提升到“造布局”的关键一课。
课后练习建议:找一个你觉得“用组件拼很别扭”的 UI,试着用 Canvas 画出来。比如一个带渐变和圆角端点的环形进度条,或者一个带面积填充的折线图。先画静态的,再让它动起来。画完之后,用 drawWithCache 优化一下,看看帧率有没有提升。你会发现,像素级控制的感觉,和拼组件完全不一样。
网硕互联帮助中心



评论前必须登录!
注册