27、Compose动画与Canvas:Canvas绘制动画、drawBehind与drawWithContent、自定义绘制动画
说实话,Compose的动画体系已经很强大了。但有些时候,你想要的动画效果,光靠animateXxxAsState这些API搞不定。比如一个自定义的加载指示器,或者一个动态的图表曲线。这时候,你就得亲自下场,用Canvas来画。
今天我们就聊聊,怎么在Compose里用Canvas做动画。我会结合我自己的踩坑经历,把drawBehind、drawWithContent这些关键API讲透。
Canvas绘制动画的基本思路
Compose里的Canvas,说白了就是一个画板。你可以在上面画圆、画线、画路径。动画的本质,就是让这些绘制参数随时间变化。
我个人习惯的做法是:先用remember定义一个动画值,然后把这个值传给Canvas的绘制逻辑。每次动画值更新,Canvas就会重绘,动画就出来了。
举个最简单的例子——一个呼吸光晕效果:
@Composable
fun BreathingGlow() {
val alpha = rememberInfiniteTransition().animateFloat(
initialValue = 0.3f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)
Canvas(modifier = Modifier.size(100.dp)) {
drawCircle(
color = Color.Cyan.copy(alpha = alpha.value),
radius = size.minDimension / 2
)
}
}
你看,核心逻辑就三步:定义动画值、绑定到绘制参数、交给Canvas渲染。嗯,就是这么简单。
drawBehind与drawWithContent
这两个API,很多人容易搞混。我刚开始用的时候也踩过坑。
drawBehind:在Composable的内容后面绘制。适合做背景动画,比如一个渐变背景在呼吸变化。
drawWithContent:你可以控制绘制顺序。先画自己的内容,再画自定义的东西;或者反过来。
我曾经在项目里做一个卡片组件,需要在卡片内容之上叠加一层动态遮罩。用drawWithContent就特别合适:
@Composable
fun CardWithOverlay() {
val progress = rememberInfiniteTransition().animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(2000),
repeatMode = RepeatMode.Reverse
)
)
Box(
modifier = Modifier
.size(200.dp, 120.dp)
.drawWithContent {
// 先画子内容
drawContent()
// 再画遮罩
drawRect(
color = Color.Black.copy(alpha = 0.3f * progress.value),
topLeft = Offset.Zero,
size = size
)
}
) {
Text("我是卡片内容", modifier = Modifier.padding(16.dp))
}
}
这里要注意:drawWithContent里必须调用drawContent(),否则子组件不会显示。我曾经忘了写这一行,调试了半天……
自定义绘制动画
当内置的绘制API不够用时,你就得自己写绘制逻辑。比如画一个动态的波浪线,或者一个旋转的齿轮。
我个人觉得,自定义绘制动画的核心在于路径动画和矩阵变换。
来看一个我实际用过的例子——一个动态的加载弧线:
@Composable
fun LoadingArc() {
val rotation = rememberInfiniteTransition().animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
val sweepAngle = rememberInfiniteTransition().animateFloat(
initialValue = 10f,
targetValue = 350f,
animationSpec = infiniteRepeatable(
animation = tween(750, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
Canvas(modifier = Modifier.size(80.dp)) {
val strokeWidth = 8.dp.toPx()
val arcRect = Rect(
strokeWidth / 2,
strokeWidth / 2,
size.width - strokeWidth / 2,
size.height - strokeWidth / 2
)
// 画背景弧
drawArc(
color = Color.Gray.copy(alpha = 0.2f),
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = strokeWidth),
topLeft = arcRect.topLeft,
size = arcRect.size
)
// 画动态弧
drawArc(
color = Color.Cyan,
startAngle = rotation.value,
sweepAngle = sweepAngle.value,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
topLeft = arcRect.topLeft,
size = arcRect.size
)
}
}
这个加载弧线,同时旋转角度和弧长都在变化。效果比普通的旋转圆圈要生动得多。
核心要点:自定义绘制动画 = 动画值 + Canvas绘制API。动画值驱动绘制参数,Canvas负责渲染。
性能注意事项
做Canvas动画,性能是个绕不开的话题。我踩过几个坑,分享给你:
- 避免在drawScope里做复杂计算:每次重绘都会执行,卡顿是必然的。把计算结果缓存到remember里。
- 合理使用clipPath:裁剪区域太复杂会拖慢渲染。能用矩形裁剪就别用贝塞尔曲线。
- 注意硬件加速:Compose默认开启硬件加速,但某些绘制操作(比如复杂的Path)可能会触发软件渲染。可以用
graphicsLayer来优化。
避坑指南:我曾经在项目里用Canvas画了一个复杂的动态图表,每帧都要计算几十个数据点的位置。结果在低端机上直接掉到20帧。后来我把数据点缓存成Path,只更新变化的部分,帧率才回到60。
知识体系图
下面这张图,帮你理清Compose Canvas动画的整体脉络:
实际项目中的经验
我在做一个音乐播放器的时候,需要实现一个动态的频谱可视化效果。一开始我用的是drawRect逐条绘制,但性能很差。后来改用drawPath一次性绘制所有柱状条,性能提升很明显。
还有一个经验:如果你需要频繁更新Canvas,可以考虑用LaunchedEffect配合withFrameMillis来做帧同步动画,而不是用rememberInfiniteTransition。前者给你更精细的控制,适合做游戏或实时数据可视化。
小技巧:调试Canvas动画时,可以在绘制逻辑里加一个drawRect来显示边界框,方便定位绘制位置。调试完再删掉就行。
好了,Canvas动画这块就聊这么多。记住一个原则:动画值驱动绘制参数,Canvas负责渲染。掌握了这个思路,你就能画出各种酷炫的自定义动画了。