mcpField
A
animations

animations

github.com/verygoodopensource设计与 UI175106 次安装3 天前更新
代码仓库

关于 animations

面向 Flutter 开发者的动画技能,依据内置动画 API 和 Material 3 动效规范指导实现与审查。重点是在足够时采用隐式动画,并避免触发布局的动画方式。

主要功能

  1. 01选择隐式或显式动画
  2. 02使用 Material 3 时长与缓动令牌
  3. 03控制器生命周期与 Ticker 指引
  4. 04交错动画与页面转场模式
  5. 05动画性能与反模式检查

适用场景

  1. 01实现目标值驱动的界面转场
  2. 02构建分步或循环动画
  3. 03审查 Flutter 动画代码
  4. 04添加 GoRouter 页面转场

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/verygoodopensource/vgv-ai-flutter-plugin --skill animations

安装后在 Claude Code 中输入/animations确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
references/
explicit-animations.md
implicit-animations.md
looping-animations.md
page-transitions.md
staggered-animations.md

详细介绍

动画

使用内置动画框架并遵循 Material 3 动效指南的 Flutter 动画最佳实践。不使用第三方动画库(Lottie、Rive 等)。

核心标准

所有动画工作都应遵循以下标准:

  • 需求含糊时,先明确视觉意图 —— 如果开发者只说“添加动画”或“让它更流畅”,却没有指定属性、触发条件、时长或曲线,请先询问,再编写代码。如果开发者提供了明确规格(例如“卡片出现时,以 300ms 时长和 ease-in 曲线淡入”),则直接着手实现
  • 采用可行的最简单动画方案 —— 遵循下方的决策树;只要隐式动画足以满足需求,就绝不要使用 AnimationController,即使要同时动画多个属性也一样
  • 即使按名称指定控制器,也坚持使用隐式形式 —— 如果目标值动画本来只需隐式动画,却要求“用 AnimationController 和 AnimatedBuilder 实现”,这就属于下方所述的反模式。请编写隐式版本,用一句话说明它为何足够,然后结束。不要一边说明一边提供控制器实现,也不要反问对方想要哪种实现。如果开发者读完理由后再次明确要求使用控制器,再实现控制器版本
  • 时长和缓动使用 Material 3 动效令牌 —— 绝不硬编码任意 Duration 或 Curve 值
  • 提取动画常量 —— 时长、曲线和偏移量应放入具名常量或集中管理的 AppMotion 类中,而非内联
  • 释放控制器 —— 每个 AnimationController 都必须在 State 的 dispose() 方法中、调用 super.dispose() 之前释放
  • 单个控制器使用 SingleTickerProviderStateMixin —— 仅当 widget 拥有多个控制器时才使用 TickerProviderStateMixin
  • 缩小动画子树范围 —— 只将发生变化的 widget 放入动画构建器中,不要包裹整个 widget 树
  • 绝不对触发布局的属性进行动画处理 —— width、height、padding 和 SizedBox 尺寸会在每一帧触发新一轮布局,无论树中只有一个子节点还是结构很深。改用 Transform:用 Transform.scale 调整大小,用 Transform.translate 调整位置,用 Opacity 实现淡入淡出。这些操作在合成层运行,不会触发布局
  • Dart 3.13 主构造函数 —— 基线版本为 Dart 3.13+ 时,将 widget 字段声明为主构造函数的声明参数(class const LabelChip({required final String label, super.key}) extends StatelessWidget),而不是使用 this.field;仅在版本低于 3.13 时保留经典形式

动画决策树

选择满足需求的最简单方案:

Does the widget rebuild when the value changes?
  |
  YES --> Does the framework provide an AnimatedFoo widget?
  |         |
  |         YES --> Use the implicit AnimatedFoo widget
  |         |       (AnimatedContainer, AnimatedOpacity, AnimatedAlign, etc.)
  |         |
  |         NO  --> Use TweenAnimationBuilder
  |
  NO  --> Do you need fine-grained control?
            (repeat, reverse, sequence, listen to status)
            |
            YES --> Use AnimationController + AnimatedBuilder
            |
            NO  --> Use TweenAnimationBuilder

经验法则: 如果动画是“设定目标值并让它动到那里”,就使用隐式动画。 如果动画必须按指令播放、暂停、反向或重复,就使用显式动画。

同时为两个属性添加动画仍属于隐式动画。 数据到达时淡入并向上滑动的卡片,可以嵌套两个隐式动画 widget,每个只设置一个目标值。同步发生不等于按顺序播放:只有当第二个属性必须在第一个开始后才开始变化,或动画需要播放控制时,才使用控制器。由标志切换驱动的入场动画——例如值到达、布尔值切换或项目出现——无论有多少属性发生变化,都属于隐式动画。


Material 3 动效令牌

使用 Flutter 内置的 Durations 和 Easing 类——新代码中绝不硬编码 Duration(milliseconds: ...),也不使用 Curves.*。这些框架常量与 Material 3 动效规范保持一致。

// Bad — arbitrary values with no semantic meaning
AnimatedContainer(
  duration: Duration(milliseconds: 375),
  curve: Curves.easeInOutCubic,
)

// Good — M3 tokens with clear intent
AnimatedContainer(
  duration: Durations.medium2,
  curve: Easing.standard,
)
集中管理的动效常量

如果项目在多个功能中使用动画,请引入 AppMotion 类。若应用中只有一个动画,内联使用 M3 令牌即可。

abstract class AppMotion {
  // Standard transitions
  static const Duration standardDuration = Durations.medium2;
  static const Curve standardCurve = Easing.standard;

  // Page transitions
  static const Duration pageDuration = Durations.medium4;
  static const Curve pageEnterCurve = Easing.emphasizedDecelerate;
  static const Curve pageExitCurve = Easing.emphasizedAccelerate;

  // Fades
  static const Duration fadeDuration = Durations.short3;
  static const Curve fadeCurve = Easing.standard;
}

隐式动画

当组件以新的目标值重建时,使用隐式动画。框架会自动插值。Flutter 提供了内置的 AnimatedFoo 组件(AnimatedContainer、AnimatedOpacity、AnimatedSlide、AnimatedSwitcher 等)——请使用与所要设置动画的属性相匹配的组件。如果没有内置组件可用,请使用 TweenAnimationBuilder。

当多个属性一起变化时,每个属性都组合使用一个 AnimatedFoo。下面是入场动画的模式——组件在数据到达前保持隐藏,随后淡入并滑动到位:

class const SummaryCard({required final Summary? summary, super.key})
    extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final hasData = summary != null;

    return AnimatedOpacity(
      opacity: hasData ? 1 : 0,
      duration: Durations.medium2,
      curve: Easing.standard,
      child: AnimatedSlide(
        offset: hasData ? Offset.zero : const Offset(0, 0.1),
        duration: Durations.medium2,
        curve: Easing.emphasizedDecelerate,
        child: Card(child: _SummaryContents(summary: summary)),
      ),
    );
  }
}

无需 StatefulWidget、控制器、ticker 或 dispose。两个属性会在同一次重建中开始动画。


TweenAnimationBuilder

当没有适用于该属性的内置 AnimatedFoo 组件,但仍希望使用隐式风格的“设置后无需操心”动画时,请使用 TweenAnimationBuilder。

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: isActive ? 1.0 : 0.0),
  duration: Durations.medium2,
  curve: Easing.standard,
  builder: (context, value, child) {
    return Transform.scale(
      scale: 0.8 + (0.2 * value),
      child: Opacity(
        opacity: value,
        child: child,
      ),
    );
  },
  child: child, // child is not rebuilt — optimization
)

child 参数至关重要:传入不依赖动画值的组件,以避免不必要的重建。


显式动画

当需要控制播放行为时,使用显式动画:播放、暂停、反向播放、重复播放,或监听动画状态。

AnimationController 设置
class _MyWidgetState extends State<MyWidget>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _fadeAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Durations.medium2,
      vsync: this,
    );
    _fadeAnimation = CurvedAnimation(
      parent: _controller,
      curve: Easing.standard,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _fadeAnimation,
      builder: (context, child) {
        return Opacity(
          opacity: _fadeAnimation.value,
          child: child,
        );
      },
      child: child, // static child — not rebuilt each frame
    );
  }
}

关于 didUpdateWidget 模式、用于创建可测试控制器的构造函数注入,以及过渡组件与 AnimatedBuilder 的使用建议,请参阅 references/explicit-animations.md。

使用 Interval 实现交错动画

在 CurvedAnimation 中使用 Interval,让单个控制器上的动画交错播放——滑动动画会在淡入动画开始一段时间后启动,而不是与其同时开始。Interval 区间彼此重叠,正是这种模式的核心。

如果多个属性要同时动画到目标值,就不该使用这种工具。若淡入和滑动都在同一次重建中开始,应使用两个隐式动画组件,而不是使用一个带两个区间的控制器:

late final Animation<double> _fadeAnimation = CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.0, 0.5, curve: Easing.standard),
);

late final Animation<Offset> _slideAnimation = Tween<Offset>(
  begin: const Offset(0, 0.25),
  end: Offset.zero,
).animate(
  CurvedAnimation(
    parent: _controller,
    curve: const Interval(0.2, 0.8, curve: Easing.emphasized),
  ),
);

完整的交错入场和交错列表示例,请参阅 references/staggered-animations.md。重复动画和脉冲动画模式,请参阅 references/looping-animations.md。


页面过渡

自定义页面过渡可通过 GoRouteData.buildPage 中的 CustomTransitionPage 与 GoRouter 集成。当多个路由需要页面过渡时,请将其提取到共享的 AppPageTransitions 助手中——切勿在多个路由中内联相同的 transitionsBuilder。关于该助手、GoRouter 接线和 Hero 共享元素过渡,请参阅 references/page-transitions.md。


性能

应该做
  • 为 Transform 和 Opacity 设置动画——它们作用于合成层,可跳过布局和绘制
  • 在 AnimatedBuilder 和 TweenAnimationBuilder 中使用 child 参数,避免静态组件在每一帧都重建
  • 在复杂布局中的动画组件周围使用 RepaintBoundary,以隔离重绘
不应该做
  • 不要将整个屏幕包在 AnimatedBuilder 中——只包裹发生变化的子树
  • 不要为共享计时的动画创建多个 AnimationController 实例——请在单个控制器上使用 Interval

反模式

为宽度设置动画,而不是使用 Transform
// Bad — every frame re-runs layout on the SizedBox and everything under it
AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return SizedBox(
      width: 200 + (_controller.value * 120),
      child: child,
    );
  },
  child: const ExpensiveChart(),
)

// Good — Transform.scale runs on the compositing layer, no layout pass
AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Transform.scale(
      scaleX: 1 + (_controller.value * 0.6),
      child: child,
    );
  },
  child: const ExpensiveChart(),
)

代码审查时,请明确指出此问题:动画化的 width 或 height 会在每一帧强制执行布局,解决方法是使用 Transform.scale 或 Transform.translate。

每帧重建静态子项
// Bad — entire subtree rebuilds 60 times/second
AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Opacity(
      opacity: _controller.value,
      child: const ExpensiveWidget(), // rebuilt every frame
    );
  },
)

能用隐式动画时却显式使用动画。这种情况尤其需要留意,因为通常请求一开始就已经把错误答案写进去了。

// Bad — unnecessary complexity for a simple target-value animation
class _FadeWidgetState extends State<FadeWidget>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  // ... 20+ lines of boilerplate

// Good — one widget, zero boilerplate
AnimatedOpacity(
  duration: Durations.short3,
  curve: Easing.standard,
  opacity: isVisible ? 1.0 : 0.0,
  child: child,
)

“在 StatefulWidget 中使用 AnimationController 和 AnimatedBuilder 进行设置,让动画正确接入”——对于由布尔值驱动的淡入淡出,这个请求说的正是上面那种错误写法。请给出 AnimatedOpacity 版本,简要说明理由,并省略控制器。即使代码片段符合要求,只在备注中推荐更简单的写法,最终仍会带上那些样板代码。


其他资源

评论

支持 Markdown