关键词:Flutter、CustomPainter、AnimationController、光晕环、关键帧、AnimatedBuilder
一、需求来源
「开发呼吸灯/雷达样式组件时遇到一个痛点:设计稿给的是 Aninix/Lottie 动画(72×72、60fps、89 帧),直接引 Lottie 资源包体大、还依赖第三方运行时。」
常规做法是引 Lottie 或录 GIF,问题不少:
- 包体膨胀:一个 89 帧的动画资源动辄几百 KB,还得多引一个运行时依赖。
- 还原失真:GIF 掉帧、锯齿明显,和设计稿色值对不上。
- 参数写死:颜色、线宽、尺寸想微调就得重新导出资源,没法在代码里调。
需求很朴素:用纯 Dart 在 Canvas 上逐帧还原这个光晕动画,参数全部可配。
二、使用示例

基础用法:外层传任意 child(会被裁成圆形),光环自动循环播放:
dart
AnimatedHalo(
child: NNetworkImage(url: avatarUrl, fit: BoxFit.cover),
)
核心参数一览:
dart
AnimatedHalo(
size: 72, // 画布边长,对应稿 72
color: Color(0xFF51D3B1),// 外环描边色
innerColor: Color(0xFF00B887), // 内环描边色
spacing: 6, // 外环相对内环的半径增量
strokeWidth: 2, // 外环线宽
innerStrokeWidth: 4, // 内环线宽
duration: Duration(milliseconds: 1483), // 一轮动画时长,稿面 1483ms
child: myWidget,
)
三、源码讲解
状态管理:循环播放 + 时长热更新
AnimationController..repeat() 无限循环,didUpdateWidget 里监听时长变化并重启:
dart
class _AnimatedHaloState extends State<AnimatedHalo>
with SingleTickerProviderStateMixin {
late final controller = AnimationController(
vsync: this,
duration: widget.duration,
)..repeat();
@override
void didUpdateWidget(covariant AnimatedHalo oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
controller
..duration = widget.duration
..repeat();
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
关键坑 :用 SingleTickerProviderStateMixin 而不是 TickerProviderStateMixin(只有一个动画),省掉多 Ticker 开销;dispose 必须释放 controller,否则报 "Ticker was active" 泄漏。
布局:child 裁圆 + 光环叠层
Stack 下层放 ClipOval 裁圆的内容,上层用 AnimatedBuilder 驱动 CustomPaint 画环:
dart
Widget build(BuildContext context) {
final scale = widget.size / 72;
final imageSize =
((30 - widget.innerStrokeWidth / 2) * 2 * scale).clamp(0.0, widget.size);
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
alignment: Alignment.center,
children: [
ClipOval(
child: SizedBox.square(dimension: imageSize, child: widget.child),
),
AnimatedBuilder(
animation: controller,
builder: (context, child) => CustomPaint(
size: Size.square(widget.size),
painter: _AnimatedHaloPainter(
progress: controller.value,
color: widget.color,
innerColor: widget.innerColor,
spacing: widget.spacing,
strokeWidth: widget.strokeWidth,
innerStrokeWidth: widget.innerStrokeWidth,
),
),
),
],
),
);
}
易忽略的细节 :imageSize 按稿面「内环半径 30 - 半线宽」反推内容直径,让 child 恰好在内环内侧;AnimatedBuilder 把耗时绘制隔离在 painter 层,避免整个 subtree 重建。
画笔:两环各画一条描边圆
dart
@override
void paint(Canvas canvas, Size size) {
final scale = size.shortestSide / _comp; // _comp = 72
final center = Offset(size.width / 2, size.height / 2);
final frame = progress * _frames; // _frames = 89
final innerRadius = 30 * scale;
final innerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = innerStrokeWidth * scale
..color = innerColor.withValues(alpha: _innerOpacity(frame));
canvas.drawCircle(center, innerRadius, innerPaint);
final outerOpacity = _outerOpacity(frame);
if (outerOpacity <= 0) {
return; // 关键优化:透明度为 0 直接跳过外环绘制
}
final outerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth * scale
..color = color.withValues(alpha: outerOpacity);
canvas.drawCircle(center, _outerRadius(frame, innerRadius, scale), outerPaint);
}
四、总结
这个组件核心:一个 AnimationController 循环驱动 CustomPainter,用关键帧插值在 Canvas 上逐帧还原 89 帧的 Lottie 光晕动画。
核心价值:
- 零资源依赖:纯 Dart 绘制,砍掉 Lottie 运行时和动画资源,包体明显缩小。
- 帧级还原 :
(帧号, 值)关键帧 + Cubic 缓动插值,一比一复刻设计稿曲线。 - 全参数可配:颜色、线宽、半径增量、时长全部暴露,一处封装处处微调。
- 性能抠得细 :透明度 0 提前 return、
SingleTickerProviderStateMixin、shouldRepaint精确对比,动画不拖 UI 帧率。 - 缩放适配 :稿面数值统一乘
scale,72 设计稿可自由放到任意尺寸。
设计稿的动画不该靠"搬资源",用几行 Canvas 曲线还原,包体更小、调参更自由。