Flutter 进阶 | 组件封装:用 CustomPainter 实现光环动画组件AnimatedHalo

关键词:Flutter、CustomPainter、AnimationController、光晕环、关键帧、AnimatedBuilder

一、需求来源

「开发呼吸灯/雷达样式组件时遇到一个痛点:设计稿给的是 Aninix/Lottie 动画(72×72、60fps、89 帧),直接引 Lottie 资源包体大、还依赖第三方运行时。」

常规做法是引 Lottie 或录 GIF,问题不少:

  1. 包体膨胀:一个 89 帧的动画资源动辄几百 KB,还得多引一个运行时依赖。
  2. 还原失真:GIF 掉帧、锯齿明显,和设计稿色值对不上。
  3. 参数写死:颜色、线宽、尺寸想微调就得重新导出资源,没法在代码里调。

需求很朴素:用纯 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 光晕动画。

核心价值:

  1. 零资源依赖:纯 Dart 绘制,砍掉 Lottie 运行时和动画资源,包体明显缩小。
  2. 帧级还原(帧号, 值) 关键帧 + Cubic 缓动插值,一比一复刻设计稿曲线。
  3. 全参数可配:颜色、线宽、半径增量、时长全部暴露,一处封装处处微调。
  4. 性能抠得细 :透明度 0 提前 return、SingleTickerProviderStateMixinshouldRepaint 精确对比,动画不拖 UI 帧率。
  5. 缩放适配 :稿面数值统一乘 scale,72 设计稿可自由放到任意尺寸。

设计稿的动画不该靠"搬资源",用几行 Canvas 曲线还原,包体更小、调参更自由。

github

pub: animated_halo

相关推荐
涛涛ing1 小时前
你的页面为什么总是卡成PPT?2026年,90%的前端都忽略了主线程
前端
Light Gao1 小时前
企业级移动端 APP 架构设计:从原生双端到 Hybrid、React Native 与 Flutter
flutter·react native·react.js
IT_陈寒1 小时前
JavaScript的隐式转换太坑了,我的==比较怎么就炸了?
前端·人工智能·后端
雪芽蓝域zzs1 小时前
第十四节:后端返回权限动态路由
前端·javascript·vue.js
Rain5091 小时前
谁动了我的 URL?——记一次微前端“灵异 Bug“的排查实录
前端·vue.js·人工智能·前端框架·bug·ai编程
bjzhang752 小时前
使用HTML+CSS美化上传进度条展示
前端·css·html
何以解忧,唯有..2 小时前
Vue 3 响应式核心:ref() 与 reactive() 的深入解析与实战
前端·javascript·vue.js
晴天162 小时前
Chrome DevTools Protocol(CDP)分享-Day36
前端·chrome·chrome devtools
风骏时光牛马2 小时前
程序员的职场成长:技术之外,更要修炼底层思考力
前端