
在上一篇讲解绘制的文章中,我们聊了 DrawScope 的基础:怎么进入绘制阶段,怎么用 drawBehind、drawWithContent、drawWithCache,以及为什么像素对齐和 Path 缓存会影响实际性能。
这篇文章,我们继续往下走。
只会 drawCircle()、drawRect() 肯定是还不够的。如果你想做旋转指针、环形进度、轨道加载、缩放呼吸效果等,就一定会碰到这几个 API:
kotlin
translate(...)
rotate(...)
scale(...)
withTransform { ... }
clipRect { ... }
clipPath { ... }
它们看起来都很简单,但第一次用的时候很容易懵。
有一点需要先记住:你以为自己是在移动某个图形,实际上你是在改变坐标系。
踩坑
比如我们想画一个指针。
需求很简单:指针从中心出发,随着角度旋转,末端再画一个小圆。
很多人第一反应会这么写:
kotlin
Modifier.drawBehind {
val handLength = 58.dp.toPx()
drawDialBackground() // 绘制背景
rotate(degrees = angle) {
drawLine(
color = Color(0xFFE53935),
start = Offset.Zero,
end = Offset(0f, -handLength),
strokeWidth = 4.dp.toPx()
)
drawCircle(
color = Color(0xFF1E88E5),
radius = 6.dp.toPx(),
center = Offset(0f, -handLength)
)
}
}
看起来没问题:先旋转,再画线,最后在末端画圆。
但实际效果大概率不对。原因有两个:
Offset.Zero还是组件左上角,不是表盘中心。rotate默认围绕当前DrawScope的中心旋转坐标系,而drawLine和drawCircle里的坐标,都会被这个旋转后的坐标系重新解释。
如果不信,直接看效果:

换句话说,你写下的每一个 Offset 都不是一个永远不变的屏幕绝对位置。它是当前坐标系里的一个点。坐标系一变,这个点的含义也跟着变。
把原点挪到中心
写绘制代码时,一个很好用的习惯是:把复杂图形的"局部原点"放到你真正关心的位置。
对于指针来说,我们真正关心的是表盘中心。那么就先 translate 到中心,再把后续绘制都写成相对坐标。
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
val handLength = 58.dp.toPx()
drawDialBackground()
translate(left = center.x, top = center.y) {
rotate(degrees = angle, pivot = Offset.Zero) {
drawLine(
color = Color(0xFFE53935),
start = Offset.Zero,
end = Offset(0f, -handLength),
strokeWidth = 4.dp.toPx()
)
drawCircle(
color = Color(0xFF1E88E5),
radius = 6.dp.toPx(),
center = Offset(0f, -handLength)
)
}
}
}
这段代码读起来就清楚多了:
- 先把坐标系原点移动到组件中心。
- 再围绕这个新原点旋转,所以这里要显式写
pivot = Offset.Zero。 - 最后在局部坐标系里画指针和末端圆点。
这里的 Offset.Zero 就是表盘中心,Offset(0f, -handLength) 就是指针末端。
这就是 DrawScope 变换里最重要的一个概念:你不是在移动图形,你是在移动后面那几行绘制代码所处的坐标系。
再看修正后的效果:

translate
先从最简单的 translate 开始。
kotlin
Modifier.drawBehind {
drawCircle(
color = Color.LightGray,
radius = 30.dp.toPx(),
center = Offset.Zero
)
translate(left = 100.dp.toPx(), top = 100.dp.toPx()) {
drawCircle(
color = Color.Blue,
radius = 30.dp.toPx(),
center = Offset.Zero
)
}
}
第一个圆画在左上角。
第二个圆看起来像是画在 (100dp, 100dp),但更准确地说,是坐标系的原点被移动到了 (100dp, 100dp),然后你又在新的 Offset.Zero 上画了一个圆。

背景是为了显示绘制区域,让大家更容易看清当前的相对位置。
这个区别很重要。因为一旦你接受"原点变了",也就是"坐标系变了",后面的 rotate 和 scale 就好理解了。
rotate
kotlin
Modifier.drawBehind {
drawRect(Color.LightGray)
rotate(degrees = 45f) {
drawRect(Color.Red)
}
}
这里不是"红色矩形自己转了 45 度"。
如果你理解了上面的说法,就会明白:在 rotate 代码块里,坐标系已经旋转了 45 度。你在里面调用 drawRect(),这个矩形当然就会出现在旋转后的坐标系里。

在代码块结束后,Compose 会帮你恢复之前的绘制状态。你不用像传统 Canvas 那样手动 save() / restore(),也就不容易出现状态没有恢复、导致后续绘制全部歪掉的问题。
还有一个细节要记住:DrawScope.rotate 默认使用当前 DrawScope 的中心作为 pivot。如果你已经用 translate 把局部原点挪到了目标位置,并且希望围绕这个局部原点旋转,就要显式传 pivot = Offset.Zero。
scale
scale 也是一样。
kotlin
Modifier.drawBehind {
scale(scale = 1.5f) {
drawCircle(
color = Color.Green,
radius = 30.dp.toPx(),
center = Offset.Zero
)
}
}
你可以把它理解成:坐标系里的"单位"变大了。
所以不只是圆的半径会被放大,圆心位置、线宽、后续所有绘制命令都会受到影响。
如果你只是想让某个半径变大,直接改 radius 就行。如果你想让一整组图形一起放大缩小,用 scale 会是更自然的写法。
顺序很重要
一看到"顺序很重要"这句话,我又想起了 Modifier 的调用顺序,不过,Modifier 的调用顺序可能以后不再重要了。
变换最容易写错的地方,不是 API 名字,而是顺序。
一句话记住:每一次变换,都会改变下一行绘制代码所处的世界。
看两个场景。
原地旋转
要让图形原地旋转,通常是先 translate,再 rotate。更准确地说,是先把局部原点移到目标位置,再围绕这个局部原点旋转。
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
translate(center.x, center.y) {
rotate(degrees = angle, pivot = Offset.Zero) {
drawRect(
color = Color.Red,
topLeft = Offset(-20.dp.toPx(), -20.dp.toPx()),
size = Size(40.dp.toPx(), 40.dp.toPx())
)
}
}
}
这段代码的效果是:矩形围绕自己的局部中心旋转。

因为你先把原点挪到了中心,再围绕这个新原点旋转。
公转
先 rotate,再 translate,则是公转效果。
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
val orbitRadius = 80.dp.toPx()
translate(center.x, center.y) { // 先把局部原点移动到公转中心
rotate(degrees = angle, pivot = Offset.Zero) {
translate(left = orbitRadius, top = 0f) { // 再沿旋转后的 X 轴移动半径距离
drawCircle(
color = Color.Cyan,
radius = 10.dp.toPx(),
center = Offset.Zero
)
}
}
}
}
这段代码的效果是:小圆围绕中心转。

逻辑是这样的:
- 先把原点移到画布中心。
- 再把坐标系旋转
angle。 - 然后沿着旋转后的 X 轴移动
orbitRadius。 - 最后在新的
Offset.Zero上画圆。
这就是公转效果的核心:先旋转,再平移。
pivot
pivot 有个坑:默认值别想当然。
如果你只是想让一个图形围绕某个点旋转,不用手写 translate + rotate。
DrawScope.rotate 本身就支持 pivot:
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
rotate(
degrees = angle,
pivot = center
) {
drawRect(
color = Color.Magenta,
topLeft = center - Offset(40.dp.toPx(), 40.dp.toPx()),
size = Size(80.dp.toPx(), 80.dp.toPx())
)
}
}

pivot 可以理解成"围绕哪个点旋转"。
这个效果和上面的 translate + rotate 很接近。
不过需要注意,DrawScope.rotate 的默认 pivot 是当前绘制区域的中心,不是 Offset.Zero(左上角)。这点和很多人直觉不一样。
所以我还是建议你先理解 translate + rotate(pivot = Offset.Zero) 的写法。因为一旦效果复杂起来,比如一个行星带着卫星转动,局部坐标系会比到处算绝对坐标清楚得多。
withTransform
如果你需要同时做多次变换,可以用 withTransform。
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
withTransform({
translate(center.x, center.y)
rotate(degrees = angle, pivot = Offset.Zero)
scale(scaleX = 1.2f, scaleY = 1.2f, pivot = Offset.Zero)
}) {
drawCircle(
color = Color.Yellow,
radius = 30.dp.toPx(),
center = Offset.Zero
)
}
}

好处是结构更集中:上面描述坐标系怎么变,下面描述在这个坐标系里画什么。
复杂动画里我更喜欢这种写法,尤其是你要把几层变换嵌套起来的时候。
嵌套变换:实战行星和卫星
现在回到一个稍微复杂一点的场景:一个小球绕中心转,小球旁边还有一个卫星绕它转。
如果你用绝对坐标算,会很快变成三角函数大杂烩。
但用局部坐标系就很顺:
kotlin
Modifier.drawBehind {
val center = Offset(size.width / 2f, size.height / 2f)
val orbitRadius = 90.dp.toPx()
val moonRadius = 24.dp.toPx()
translate(center.x, center.y) {
rotate(degrees = planetAngle, pivot = Offset.Zero) {
translate(orbitRadius, 0f) {
drawCircle(
color = Color.Cyan,
radius = 12.dp.toPx(),
center = Offset.Zero
)
rotate(degrees = moonAngle, pivot = Offset.Zero) {
translate(moonRadius, 0f) {
drawCircle(
color = Color.White,
radius = 4.dp.toPx(),
center = Offset.Zero
)
}
}
}
}
}
}

这段代码里有三个坐标系:
- 画布坐标系:原点在组件左上角。
- 中心坐标系:原点在画布中心。
- 行星坐标系:原点在行星当前位置。
卫星的绘制完全不用知道"太阳中心"在哪里。它只要知道自己相对行星的位置。
这就是嵌套变换的价值:你不用把所有东西都换算成全局坐标。
一点点数学震撼
如果只是为了写 Compose 代码,你不需要天天手算矩阵。但知道一点底层模型,会让你更容易判断为什么顺序会影响结果。
默认情况下,画布坐标系可以理解成两个方向,也就是两个基向量:
- X 轴:向右,写成
(1, 0)。 - Y 轴:向下,写成
(0, 1)。
这两个方向组成了一个最普通的坐标系。用矩阵写出来就是单位矩阵:
text
[1 0]
[0 1]
它的意思是:X 方向还是 X 方向,Y 方向还是 Y 方向,什么都没变。
所以当你画一个点 Offset(3f, 2f) 时,可以粗略理解成:
text
(3, 2) × 单位矩阵 = (3, 2)
结果还是 (3, 2)。
scale 改的是单位长度
如果调用 scale(2f),可以理解成坐标系变成了这样:
text
[2 0]
[0 2]
这时 X 方向的一个单位变成 2px,Y 方向的一个单位也变成 2px。
所以你在里面画 radius = 30f 的圆,看起来会变成 60px。不是圆这个对象自己偷偷改了半径,而是当前坐标系里的单位长度变了。
如果只想横向拉伸,可以理解成:
text
[2 0]
[0 1]
X 方向被放大,Y 方向不变。矩形就会变宽,但高度不变。
rotate 改的是方向
旋转稍微绕一点,但核心还是同一件事:它改变了 X/Y 两个基向量的方向。
在 Android / Compose 的画布坐标系里,Y 轴向下,所以顺时针旋转 θ 可以写成:
text
[cosθ -sinθ]
[sinθ cosθ]
假设旋转 60 度:
text
cos60° = 0.5
sin60° ≈ 0.866
[0.5 -0.866]
[0.866 0.5 ]
这意味着:
- 原来的 X 方向
(1, 0),会变成(0.5, 0.866),也就是右下方。 - 原来的 Y 方向
(0, 1),会变成(-0.866, 0.5),在画布坐标里是左下方。
所以当你在 rotate 代码块里继续写:
kotlin
drawCircle(center = Offset(100f, 0f), radius = 10f)
这个 Offset(100f, 0f) 的含义已经不是"往屏幕右边走 100px"了,而是"沿着旋转后的 X 轴走 100px"。
这就是为什么很多图形会"飞到奇怪的位置"。不是它飞了,是你还在用旧坐标系的直觉理解新坐标系里的点。
为什么 2x2 矩阵不够
缩放和旋转都可以用上面的 2x2 矩阵表示,因为它们改变的是方向和长度。
但平移不一样。
translate(100f, 50f) 的意思是:不管原来的点在哪里,都额外加上 (100, 50)。
text
x' = x + 100
y' = y + 50
单纯的 2x2 线性变换做不到这件事,因为它只能表达"把 x/y 乘一下、混一下",不能凭空加一个固定偏移量。
所以图形系统通常会用齐次坐标,把二维点扩展成三维形式:
text
[a b tx] [x] [a·x + b·y + tx]
[c d ty] × [y] = [c·x + d·y + ty]
[0 0 1] [1] [ 1 ]
这里的 a, b, c, d 负责旋转、缩放、错切,tx, ty 负责平移。
你不需要在业务代码里手写这个矩阵,但知道它的存在,就能理解为什么 translate、rotate、scale 最后可以被 Compose 放进同一套变换状态里处理。
如果你以前在 View 里写过变换(还记得 Matrix 吗?),这些内容应该不会陌生。
为什么顺序会影响结果
矩阵乘法有一个很烦但很重要的特点:顺序不能随便换。
text
A × B 不一定等于 B × A
这就是为什么:
kotlin
translate(center.x, center.y) {
rotate(degrees = angle, pivot = Offset.Zero) {
drawCircle(...)
}
}
和:
kotlin
rotate(degrees = angle) {
translate(center.x, center.y) {
drawCircle(...)
}
}
不是一回事。
前者是先把局部原点挪到中心,再围绕这个局部原点旋转。后者是先把整个坐标系旋转,再沿着旋转后的坐标轴去平移。
写动画的时候,很多 bug 都来自这里。你以为自己只是把两行代码换了个顺序,但在矩阵层面,整个变换链已经变了。
回到 Compose:真正要记住什么
前面的矩阵不是为了让你手算,而是为了支撑几个判断:
translate会改变Offset.Zero的位置。rotate会改变"向右"和"向下"的方向。scale会改变单位长度。- 调用顺序不同,结果就不同。
Compose 会维护当前绘制状态,后续绘制命令都会受这个状态影响。
裁剪
clipRect 和 clipPath 也经常跟变换一起出现,但它们做的事情不一样。
translate、rotate、scale 改变的是"画在哪里"。
clipRect 和 clipPath 改变的是"哪些地方允许画出来"。
kotlin
Modifier.drawBehind {
clipRect(
left = 0f,
top = 0f,
right = size.width / 2f,
bottom = size.height
) {
drawCircle(
color = Color.Red,
radius = size.minDimension / 2f,
center = Offset(size.width / 2f, size.height / 2f)
)
}
}
这段代码里,圆还是按原来的位置绘制,只是右半部分会被裁掉。

clipPath 可以做更复杂的裁剪,比如星形、圆角头像、自定义遮罩。但它的成本也更高,尤其是路径复杂、边缘还需要抗锯齿的时候。
所以,能用 clipRect 就别上来就用 clipPath。只有当形状本身确实需要复杂路径时,再考虑 clipPath。
性能上要注意什么
变换通常比你在 Kotlin 层手动计算一堆点要划算。
比如你要画 16 个拖尾圆,让它们沿轨道排开。你可以每个点都手算 sin/cos,也可以复用同一套旋转和平移逻辑:
kotlin
repeat(16) { index ->
val trailAngle = angle - index * 8f
val alpha = 1f - index / 16f
translate(center.x, center.y) {
rotate(degrees = trailAngle, pivot = Offset.Zero) {
translate(orbitRadius, 0f) {
drawCircle(
color = Color.Cyan.copy(alpha = alpha),
radius = 8.dp.toPx(),
center = Offset.Zero
)
}
}
}
}

这类写法的好处是:绘制逻辑更稳定,位置关系也更容易看懂。
不过也别把"变换很便宜"理解成"随便写都没成本"。真正影响性能的还有:
- 每帧创建多少对象。
- 绘制命令数量有多少。
Path、Brush、Shader这类对象有没有缓存。- 裁剪路径是不是过于复杂。
- 动画是否导致不必要的重组和布局。
如果绘制里需要复用 Path 或渐变,还是上一篇说的老规矩:优先考虑 drawWithCache。
一点想法
上一篇文章我们解决的是"为什么要用 DrawScope":当一个效果本质上只是绘制问题时,直接进入绘制阶段,往往比堆一层又一层 Composable 更直接,也更容易避开不必要的重组和布局开销。
这一篇解决的是"进入 DrawScope 之后怎么把图形动起来":translate、rotate、scale 不是在单独移动某个图形,而是在改变后续绘制命令所在的坐标系。只要把这个模型想明白,很多看起来玄乎的动画,其实都可以拆成几层局部坐标系:先把原点放到合适的位置,再决定旋转、平移、缩放的顺序。
所以,DrawScope 真正有价值的地方不只是"能画",而是它给了你一种更低成本组织视觉效果的方式:静态部分用路径和缓存控制对象分配,动态部分用变换控制位置关系。前者解决性能底线,后者解决表达能力。
写到这里,Compose 自定义绘制的基本框架就比较完整了:知道什么时候该画,知道在哪一层画,也知道怎么通过坐标系变换把图形组织起来。后面再遇到进度条、仪表盘、加载动画、粒子轨迹这类效果,就不用一上来堆布局节点了,先想想:这个效果是不是可以直接在 DrawScope 里画出来。