Flutter 基础体系 · 第 45/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 隐式动画:Tween、曲线、状态切换和适用边界

Flutter 中的动画通常分为两类:

  • 显式动画(explicit animation):开发者直接创建并管理 AnimationController,决定何时播放、暂停、反向、重复以及如何释放资源。
  • 隐式动画(implicit animation):开发者只修改目标状态并提供 duration,组件负责创建控制器、驱动插值、重建界面和释放动画资源。

隐式动画并不意味着“没有动画控制器”,而是控制器的生命周期和播放过程由 Flutter 组件内部管理。开发者主要描述“从当前值变化到什么值”,而不是手动编写“每一帧如何变化”。


一、隐式动画的基本数据流

一个隐式动画通常包含以下几个步骤:

  1. State 中保存一个目标状态。
  2. 事件处理函数调用 setState 修改状态。
  3. Flutter 重新执行 build
  4. 隐式动画组件发现某个动画属性发生变化。
  5. 组件从当前显示值向新的目标值插值。
  6. 每一帧重新构建或绘制相关内容。
  7. 动画完成后保持最终值。

例如:

class SizeDemo extends StatefulWidget {
  const SizeDemo({super.key});

  @override
  State<SizeDemo> createState() => _SizeDemoState();
}

class _SizeDemoState extends State<SizeDemo> {
  bool expanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        AnimatedContainer(
          duration: const Duration(milliseconds: 400),
          width: expanded ? 240 : 120,
          height: expanded ? 180 : 80,
          color: expanded ? Colors.blue : Colors.grey,
          child: const Center(
            child: Text('Box'),
          ),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            setState(() {
              expanded = !expanded;
            });
          },
          child: const Text('切换'),
        ),
      ],
    );
  }
}

点击按钮时,代码没有调用“播放动画”的方法。它只修改了 expanded

expanded = !expanded;

之后的动画由 AnimatedContainer 根据新旧属性自动完成。

这里的因果关系是:

用户点击
  ↓
setState 修改 expanded
  ↓
build 得到新的 width、height、color
  ↓
AnimatedContainer 发现目标值变化
  ↓
内部动画从当前值插值到新值
  ↓
每帧更新渲染结果

setState 本身不会生成动画。它只是通知 Flutter:当前 State 对应的界面描述已经变化,需要在后续帧重新构建。是否有动画,取决于重新构建后的组件是否具备动画能力。


二、Tween 是什么:从起点到终点的插值规则

2.1 Tween 不是动画本身

Tween<T> 表示一个从起点到终点的值映射:

final tween = Tween<double>(
  begin: 0,
  end: 100,
);

它描述的是:

进度为 0 时,值是 0
进度为 1 时,值是 100
中间进度时,根据插值规则计算值

可以把它形式化为:

x(p)=lerp(a,b,p)x(p) = \operatorname{lerp}(a, b, p)

其中:

  • aa 是起点,也就是 begin
  • bb 是终点,也就是 end
  • pp 是进度,通常位于 01
  • x(p)x(p) 是进度为 pp 时的实际值
  • lerp 是线性插值或某种类型对应的插值函数

对于 double,插值通常可以理解为:

x(p)=a+(ba)px(p) = a + (b-a)p

例如:

a = 10
b = 30
p = 0.25

x(0.25) = 10 + (30 - 10) × 0.25
         = 15

因此,Tween<double> 不是“每隔多少毫秒改变一次值”,也不负责启动或停止。它只负责回答:

当前动画进度对应的值是多少?

真正让进度从 0 变化到 1 的,是动画控制器或隐式动画组件内部的控制逻辑。


2.2 不同类型使用不同的 Tween

Flutter 根据值类型提供了不同的插值实现:

Tween<double>(begin: 0, end: 1)
ColorTween(begin: Colors.red, end: Colors.blue)
SizeTween(begin: Size.zero, end: const Size(100, 100))
EdgeInsetsTween(
  begin: EdgeInsets.zero,
  end: const EdgeInsets.all(24),
)

这些类型的插值逻辑并不相同:

  • double 对数值做线性插值。
  • ColorTween 对颜色的颜色空间分量进行插值。
  • SizeTween 分别插值宽度和高度。
  • EdgeInsetsTween 分别插值四条边的间距。

因此,Tween 的关键不是名字,而是它定义了“两个值之间如何过渡”。

某些值不能自然插值,例如:

final a = TextStyle(fontWeight: FontWeight.w400);
final b = TextStyle(fontWeight: FontWeight.w900);

TextStyle 可以通过 TextStyle.lerp 处理许多属性,但并不是所有复杂对象都具备合理的插值规则。一个对象如果没有对应的 lerp 逻辑,就不能仅凭“值发生变化”自动产生平滑过渡。


2.3 Tween 的边界值与空值

Tween 的 beginend 可以是空值,具体行为取决于 Tween 类型。例如 ColorTween 支持从透明或空颜色状态过渡,某些布局 Tween 也允许空值。

使用 TweenAnimationBuilder 时,若首次传入的 Tween 没有有效的 begin,动画通常会从 end 的当前值开始,因此首次构建可能看不到从某个默认值进入的动画。

如果需要明确的首次进入动画,应显式设置起点:

TweenAnimationBuilder<double>(
  tween: Tween<double>(
    begin: 0,
    end: 1,
  ),
  duration: const Duration(milliseconds: 500),
  builder: (context, value, child) {
    return Opacity(
      opacity: value,
      child: child,
    );
  },
  child: const Text('淡入内容'),
)

这里的含义是:

首次动画开始时 opacity = 0
动画结束时 opacity = 1

三、曲线:改变时间分配,而不是改变目标值

3.1 线性进度并不等于线性视觉效果

如果一个 400 毫秒的动画在每个时间点都按相同比例前进,那么它使用的是线性曲线:

时间进度 p:0.00 → 0.25 → 0.50 → 0.75 → 1.00
实际进度: 0.00 → 0.25 → 0.50 → 0.75 → 1.00

这意味着速度基本恒定,开始和结束都没有减速。

曲线 Curve 的作用是把原始时间进度转换为实际插值进度:

q=C(p)q = C(p)

然后 Tween 使用 qq,而不是直接使用 pp

x(p)=lerp(a,b,C(p))x(p) = \operatorname{lerp}(a, b, C(p))

其中:

  • pp 是经过时间归一化得到的原始进度。
  • CC 是曲线函数。
  • qq 是经过曲线变换后的实际进度。
  • x(p)x(p) 是最终显示值。

例如,假设目标值从 0 变为 100:

原始进度 p = 0.25
曲线变换 C(p) = 0.10

实际值 = 0 + (100 - 0) × 0.10
       = 10

如果使用线性曲线,同一时刻的值是 25;使用缓出曲线时,动画可能只前进到 10,表示开始阶段更慢,后续再加速或根据曲线继续变化。


3.2 常见曲线的行为

AnimatedContainer(
  duration: const Duration(milliseconds: 500),
  curve: Curves.easeInOut,
  width: expanded ? 300 : 100,
  height: 100,
  color: Colors.blue,
)

常见曲线可以按运动特征理解:

  • Curves.linear:速度近似恒定。
  • Curves.easeIn:开始慢,随后加速。
  • Curves.easeOut:开始快,随后减速。
  • Curves.easeInOut:开始和结束都较缓。
  • Curves.fastOutSlowIn:常用于界面状态转换。
  • Curves.bounceOut:结束阶段出现弹跳效果。
  • Curves.elasticOut:可能越过终点后回弹。

曲线不是装饰参数,而是在改变“时间如何分配给空间变化”。同样的起点、终点和持续时间,曲线不同,用户感知到的速度就不同。


3.3 曲线可能产生越界值

许多开发者默认曲线输出一定在 [0, 1] 内,但具有回弹或弹性效果的曲线可能暂时超出这个范围。

若:

begin = 0
end = 100
curve(t) = 1.1

则:

x=0+(1000)×1.1=110x = 0 + (100 - 0) \times 1.1 = 110

这就是“越过终点后回弹”的数学基础。

这种行为对位移、缩放等效果通常是合理的,但对某些属性可能造成问题:

  • 缩放值暂时大于预期。
  • 边距短暂变成负数。
  • 透明度可能超出合法范围,具体组件可能进行限制或产生异常视觉效果。
  • 宽高越界导致布局溢出。

因此,使用弹性曲线时,必须确认目标属性能够接受超调。对于必须严格限制在合法范围内的值,应使用有界曲线,或在插值结果上进行约束。


四、常用隐式动画组件

Flutter 的隐式动画组件大致可以分为三类:

  1. 对常用属性提供封装。
  2. 对状态切换提供过渡。
  3. 提供通用 Tween 插值能力。

4.1 AnimatedContainer:同时动画多个容器属性

AnimatedContainer 适合以下属性的变化:

  • 宽度和高度
  • 内边距
  • 外边距
  • 对齐方式
  • 背景颜色
  • 装饰对象
  • 圆角
  • 阴影
  • 变换等

示例:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeOut,
  padding: selected
      ? const EdgeInsets.all(24)
      : const EdgeInsets.all(12),
  decoration: BoxDecoration(
    color: selected ? Colors.blue : Colors.white,
    borderRadius: BorderRadius.circular(selected ? 24 : 8),
    boxShadow: selected
        ? const [
            BoxShadow(
              color: Colors.black26,
              blurRadius: 12,
              offset: Offset(0, 4),
            ),
          ]
        : const [],
  ),
  child: const Text('可选卡片'),
)

它并不是把整个 BoxDecoration 当作一个普通对象直接做引用替换,而是通过 Flutter 对相关类型提供的插值能力计算中间状态。

不过,AnimatedContainer 的能力边界也很明确:

  • 不能自动动画任意自定义对象。
  • 不能保证所有复杂 decoration 属性都以符合产品意图的方式过渡。
  • 布局属性变化可能触发父子布局重新计算。
  • 子树内容本身不会因为放在 AnimatedContainer 中就自动淡入或切换。

例如,下面这段代码可以动画容器颜色,但不会让 child 中的文字内容自动淡出再换入:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  color: active ? Colors.green : Colors.red,
  child: Text(active ? '启用' : '停用'),
)

颜色会变化,文字会在某次重建时直接替换。如果需要文字切换动画,应使用 AnimatedSwitcher


4.2 AnimatedOpacity:透明度变化

AnimatedOpacity(
  opacity: visible ? 1.0 : 0.0,
  duration: const Duration(milliseconds: 250),
  child: const Icon(Icons.info),
)

透明度为 0 时,子组件通常仍然参与布局,也可能仍然响应命中测试。因此,隐藏一个按钮不能只依靠透明度:

AnimatedOpacity(
  opacity: enabled ? 1 : 0,
  child: ElevatedButton(
    onPressed: onPressed,
    child: const Text('提交'),
  ),
)

这里按钮虽然看不见,但仍可能占据空间并参与交互。常见处理方式是结合状态控制:

IgnorePointer(
  ignoring: !enabled,
  child: AnimatedOpacity(
    opacity: enabled ? 1 : 0,
    duration: const Duration(milliseconds: 250),
    child: const Text('内容'),
  ),
)

如果连布局空间也不应保留,则需要使用条件构建、AnimatedSizeAnimatedSwitcher 或其他适合布局变化的组件。


4.3 AnimatedDefaultTextStyle:文本样式变化

AnimatedDefaultTextStyle(
  duration: const Duration(milliseconds: 300),
  style: selected
      ? const TextStyle(
          fontSize: 22,
          fontWeight: FontWeight.bold,
          color: Colors.blue,
        )
      : const TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.normal,
          color: Colors.black,
        ),
  child: const Text('状态标题'),
)

它适合动画化字体大小、颜色、字重等可插值的文本样式属性。

但文本样式变化可能导致文本重新排版:

  • 字体变大后可能换行。
  • 文本高度变化可能推动其他组件移动。
  • 不同平台的字体和字形度量不同,最终布局不一定完全一致。

因此,文本动画不能只观察文字本身,还要检查父布局是否允许它在过渡过程中改变尺寸。


4.4 AnimatedPositionedAnimatedAlign:布局位置变化

AnimatedPositioned 需要作为 Stack 的子组件使用:

Stack(
  children: [
    AnimatedPositioned(
      duration: const Duration(milliseconds: 400),
      left: selected ? 160 : 20,
      top: 20,
      child: const CircleAvatar(),
    ),
  ],
)

它改变的是布局位置,通常会触发布局过程。

如果只是希望把一个已经布局完成的对象平移,可以考虑 AnimatedSlide

AnimatedSlide(
  duration: const Duration(milliseconds: 400),
  offset: selected ? const Offset(1, 0) : Offset.zero,
  child: const SizedBox(
    width: 80,
    height: 80,
    child: ColoredBox(color: Colors.blue),
  ),
)

两者的核心差别是:

  • AnimatedPositioned 改变布局位置,可能影响布局计算。
  • AnimatedSlide 通常通过绘制阶段的偏移实现视觉移动,不等价于改变布局占位。

如果后续组件不应受到移动对象位置变化的影响,AnimatedSlide 往往更符合语义;如果对象在 Stack 中的布局位置本身就是状态的一部分,AnimatedPositioned 更合适。


五、TweenAnimationBuilder:通用的隐式 Tween 动画

当现成的 AnimatedContainerAnimatedOpacity 等组件无法表达需求时,可以使用 TweenAnimationBuilder<T>

完整示例:

import 'dart:math' as math;

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    home: TweenDemoPage(),
  ));
}

class TweenDemoPage extends StatefulWidget {
  const TweenDemoPage({super.key});

  @override
  State<TweenDemoPage> createState() => _TweenDemoPageState();
}

class _TweenDemoPageState extends State<TweenDemoPage> {
  bool active = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('TweenAnimationBuilder')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TweenAnimationBuilder<double>(
              tween: Tween<double>(
                begin: 0,
                end: active ? 1 : 0,
              ),
              duration: const Duration(milliseconds: 600),
              curve: Curves.easeOutBack,
              builder: (context, value, child) {
                return Transform.rotate(
                  angle: value * math.pi * 2,
                  child: Transform.scale(
                    scale: 0.8 + value * 0.2,
                    child: child,
                  ),
                );
              },
              child: const Icon(
                Icons.favorite,
                size: 96,
                color: Colors.red,
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  active = !active;
                });
              },
              child: Text(active ? '反向目标' : '播放'),
            ),
          ],
        ),
      ),
    );
  }
}

运行条件:

  • 创建一个 Flutter 项目。
  • 将代码放入 lib/main.dart
  • 执行 flutter run
  • 当前稳定 Flutter 和 Dart 3 均支持该写法。

动画过程可以形式化为:

active = false
Tween: begin = 0, end = 0
builder 得到 value = 0

点击按钮
active = true
Tween: begin = 0, end = 1
动画进度从 0 变化到 1
builder 每帧得到新的 value

点击后,builder 中的 value 不是布尔值,而是 Tween 在当前曲线进度下计算出的 double。它可以用于:

  • 旋转角度
  • 缩放比例
  • 自定义绘制参数
  • 渐变位置
  • 自定义颜色或尺寸
  • 多个属性的联动

child 参数用于放置不依赖动画值的子树:

builder: (context, value, child) {
  return Transform.scale(
    scale: value,
    child: child,
  );
},
child: const ExpensiveWidget(),

这样做的语义是:动画值变化时,builder 会重新执行,但传入的 child 可以复用,不必在 builder 中重复创建不随动画变化的子树。


5.1 不要复用或修改同一个 Tween 实例

TweenAnimationBuilder 会管理传入 Tween 的动画过程,并可能修改 Tween 的起点以实现连续追踪。因此,不应把同一个可变 Tween 实例在多个组件之间复用,也不应在外部继续修改它。

推荐这样写:

TweenAnimationBuilder<double>(
  tween: Tween<double>(
    begin: 0,
    end: progress,
  ),
  duration: const Duration(milliseconds: 300),
  builder: (context, value, child) {
    return LinearProgressIndicator(value: value);
  },
)

不应把一个全局 Tween 实例同时交给多个动画组件:

// 不推荐
final sharedTween = Tween<double>(begin: 0, end: 1);

因为 Tween 是可变对象,不是不可变配置值。共享它会使不同动画之间产生隐蔽的数据耦合。


六、状态切换:为什么需要 Key

隐式动画不仅用于数值变化,也经常用于两个不同子组件之间的切换。此时最重要的概念是 Widget identity,也就是组件身份

6.1 没有 Key 时,Flutter 可能认为还是同一个子组件

下面的代码切换了文字内容:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: Text(
    active ? '已开启' : '已关闭',
  ),
)

如果两个分支最终生成的组件类型和身份不能被区分,Flutter 可能把它们视为同一个 Text 组件的属性更新,而不是“旧子树退出、新子树进入”。

为了明确告诉 Flutter 这是两个不同的状态子树,应使用不同的 Key:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  transitionBuilder: (child, animation) {
    return FadeTransition(
      opacity: animation,
      child: child,
    );
  },
  child: Text(
    active ? '已开启' : '已关闭',
    key: ValueKey(active),
  ),
)

状态变化过程可以表示为:

active = false
child = Text('已关闭', key: ValueKey(false))

点击后
active = true
child = Text('已开启', key: ValueKey(true))

AnimatedSwitcher 发现 key 不同:
旧 child 执行退出过渡
新 child 执行进入过渡
两者在过渡阶段可能同时存在

ValueKey(active) 的作用不是“开启动画”,而是区分两个子树的身份。AnimatedSwitcher 根据身份差异决定是否将子树视为切换对象。


6.2 AnimatedSwitcher 的完整状态切换示例

class StatusCard extends StatefulWidget {
  const StatusCard({super.key});

  @override
  State<StatusCard> createState() => _StatusCardState();
}

class _StatusCardState extends State<StatusCard> {
  bool loading = false;
  bool success = false;

  Future<void> submit() async {
    setState(() {
      loading = true;
      success = false;
    });

    try {
      await Future<void>.delayed(const Duration(seconds: 1));

      if (!mounted) {
        return;
      }

      setState(() {
        loading = false;
        success = true;
      });
    } catch (_) {
      if (!mounted) {
        return;
      }

      setState(() {
        loading = false;
        success = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final Widget content;

    if (loading) {
      content = const SizedBox(
        key: ValueKey('loading'),
        width: 24,
        height: 24,
        child: CircularProgressIndicator(strokeWidth: 2),
      );
    } else if (success) {
      content = const Text(
        '提交成功',
        key: ValueKey('success'),
      );
    } else {
      content = ElevatedButton(
        key: const ValueKey('button'),
        onPressed: submit,
        child: const Text('提交'),
      );
    }

    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 250),
      child: content,
    );
  }
}

这里有三个需要区分的层次:

  1. loadingsuccess 是业务状态。
  2. content 是根据业务状态生成的当前 UI 子树。
  3. ValueKeyAnimatedSwitcher 能够区分按钮、加载指示器和成功文本。

mounted 检查用于处理异步任务返回时页面已经被移除的情况。如果异步任务完成后直接调用 setState,可能出现:

State 被 dispose
异步操作稍后完成
回调仍调用 setState
Flutter 抛出 setState() called after dispose()

这不是动画特有的问题,但状态切换经常与异步请求结合,因此必须在异步回调中处理生命周期。


6.3 AnimatedSwitcher 在快速切换时的行为

如果状态在动画结束前再次变化,旧子树可能还没有退出完成,新子树就已经进入。AnimatedSwitcher 可以在过渡期间同时管理多个子树。

因此,下面这种状态变化:

A → B → C

不一定只有一个旧组件和一个新组件。快速变化时,过渡阶段可能短时间存在多个子树,实际数量取决于切换速度和动画持续时间。

这会带来两个后果:

  • 过渡内容复杂时,短时间内的布局和绘制成本会上升。
  • 如果使用没有唯一身份的 Key,多个逻辑状态可能被错误地复用为同一个子树。

Key 应该代表业务上可区分的内容身份,例如:

ValueKey(message.id)

而不是随意在每次 build 中生成随机 Key。随机 Key 会导致 Flutter 每次都认为是全新的子树,可能破坏状态保留并增加重建成本。


七、隐式动画被中断时发生什么

隐式动画不是只处理“静止状态 A 到静止状态 B”。真实应用中,目标值可能在动画完成前再次变化:

目标:100
动画进行到 40

用户再次操作
新目标:0

合理的隐式动画应从当前视觉值附近向新目标过渡,而不是强制回到旧的起点。

可以把过程抽象为:

初始目标:0 → 100
当前显示值:约 40

新目标:0
新的过渡:约 40 → 0

Flutter 的隐式动画状态会在属性更新时根据当前动画值重新建立过渡关系。这样用户快速点击展开、收起时,界面通常表现为连续反向,而不是突然跳回原点。

但要注意以下边界:

  • 目标值连续变化时,动画可能始终处于“追赶目标”的状态。
  • onEnd 只适用于真正完成某次动画的场景;如果动画不断被新状态打断,旧目标对应的完成回调不一定按业务期待执行。
  • 动画回调中修改状态时,需要避免形成无限状态更新。
  • 异步任务可能以旧顺序返回,动画组件不会替业务层解决请求竞态。

例如搜索联想中,用户先输入 a,再输入 ab

请求 a 发出
请求 ab 发出
ab 先返回
a 后返回

此时即使 UI 使用了隐式动画,仍然可能被旧请求结果覆盖。动画只负责视觉过渡,不能代替请求序列号、取消请求或结果校验。


八、生命周期:隐式组件替你管理了什么

隐式动画组件一般基于 ImplicitlyAnimatedWidget 体系实现。其状态对象内部通常会管理:

  • 动画控制器。
  • 动画时长。
  • 曲线转换。
  • 新旧属性的比较。
  • Tween 的更新。
  • 动画完成回调。
  • dispose 时的资源释放。

因此,与显式动画相比,开发者不需要手动写:

late AnimationController controller;

@override
void initState() {
  super.initState();
  controller = AnimationController(...);
}

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

但是,隐式动画仍然依赖 StatefulElement 的身份保持。如果组件被新的 Key 替换:

AnimatedContainer(
  key: ValueKey(screenId),
  duration: const Duration(milliseconds: 300),
  width: width,
)

screenId 改变时,Flutter 可能销毁旧元素并创建新元素。旧动画状态也就不会以连续方式保留。

所以,动画是否连续不仅由目标值决定,还由组件是否保持同一个 Element 身份决定。


九、完整组合示例:尺寸、颜色、旋转和文本切换

下面的示例同时展示:

  • AnimatedContainer 处理尺寸与颜色。
  • AnimatedDefaultTextStyle 处理文字样式。
  • TweenAnimationBuilder 处理旋转。
  • AnimatedSwitcher 处理不同文本子树切换。
  • setState 驱动整个状态变化。
import 'dart:math' as math;

import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(
    home: ImplicitAnimationPage(),
  ));
}

class ImplicitAnimationPage extends StatefulWidget {
  const ImplicitAnimationPage({super.key});

  @override
  State<ImplicitAnimationPage> createState() =>
      _ImplicitAnimationPageState();
}

class _ImplicitAnimationPageState extends State<ImplicitAnimationPage> {
  bool expanded = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('隐式动画示例'),
      ),
      body: Center(
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 450),
          curve: Curves.easeInOut,
          width: expanded ? 300 : 220,
          padding: const EdgeInsets.all(20),
          decoration: BoxDecoration(
            color: expanded ? Colors.blue.shade50 : Colors.grey.shade200,
            borderRadius: BorderRadius.circular(expanded ? 28 : 12),
            border: Border.all(
              color: expanded ? Colors.blue : Colors.grey,
              width: expanded ? 2 : 1,
            ),
          ),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TweenAnimationBuilder<double>(
                tween: Tween<double>(
                  begin: 0,
                  end: expanded ? 1 : 0,
                ),
                duration: const Duration(milliseconds: 450),
                curve: Curves.easeInOut,
                builder: (context, value, child) {
                  return Transform.rotate(
                    angle: value * math.pi,
                    child: child,
                  );
                },
                child: Icon(
                  expanded ? Icons.expand_less : Icons.expand_more,
                  size: 48,
                  color: Colors.blue,
                ),
              ),
              const SizedBox(height: 12),
              AnimatedDefaultTextStyle(
                duration: const Duration(milliseconds: 300),
                style: TextStyle(
                  fontSize: expanded ? 24 : 18,
                  fontWeight:
                      expanded ? FontWeight.bold : FontWeight.normal,
                  color: expanded ? Colors.blue.shade900 : Colors.black87,
                ),
                child: const Text('设置'),
              ),
              const SizedBox(height: 12),
              AnimatedSwitcher(
                duration: const Duration(milliseconds: 300),
                child: Text(
                  expanded ? '这里是展开后的详细内容。' : '点击按钮查看详情。',
                  key: ValueKey(expanded),
                  textAlign: TextAlign.center,
                ),
              ),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: () {
                  setState(() {
                    expanded = !expanded;
                  });
                },
                child: Text(expanded ? '收起' : '展开'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

这个例子的状态数据流如下:

expanded = false
  ├─ 容器较小、浅灰背景
  ├─ 图标旋转值为 0
  ├─ 标题字号较小
  └─ AnimatedSwitcher 显示收起文本

expanded = true
  ├─ 容器变大、背景和圆角变化
  ├─ 图标旋转到 π
  ├─ 标题字号变大
  └─ AnimatedSwitcher 切换到展开文本

同一次 setState 可以驱动多个独立的隐式动画。它们的持续时间和曲线可以相同,也可以不同。Flutter 并不会把这些组件自动合并为一个控制器;每个隐式组件仍然维护自己的动画状态。


十、隐式动画的适用边界

10.1 适合属性从旧值过渡到新值

隐式动画最适合这种问题:

当前状态:卡片收起
目标状态:卡片展开
需要:宽度、颜色、间距、圆角平滑变化

代码只需要描述目标状态:

AnimatedContainer(
  width: expanded ? 320 : 160,
  duration: const Duration(milliseconds: 300),
)

这类动画的特点是:

  • 由状态决定目标值。
  • 动画通常只在目标变化时播放。
  • 不需要外部手动控制进度。
  • 生命周期相对简单。

10.2 不适合复杂时间线

如果需求包含多个严格编排的阶段:

先淡出
再移动
再缩放
最后显示成功图标

或者需要:

  • 暂停和恢复。
  • 反向播放。
  • 循环。
  • 根据手势实时控制进度。
  • 拖拽过程中逐帧映射手指位置。
  • 多个动画共享同一个精确时间轴。
  • 监听动画进度并在特定区间触发逻辑。

此时显式动画通常更合适:

class ExplicitDemo extends StatefulWidget {
  const ExplicitDemo({super.key});

  @override
  State<ExplicitDemo> createState() => _ExplicitDemoState();
}

class _ExplicitDemoState extends State<ExplicitDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Animation<double> opacity;

  @override
  void initState() {
    super.initState();

    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 500),
    );

    opacity = CurvedAnimation(
      parent: controller,
      curve: Curves.easeOut,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: opacity,
      child: const Text('显式动画'),
    );
  }
}

显式动画增加了控制器和生命周期管理成本,但换来了对时间轴的直接控制。不能因为隐式动画代码更短,就把所有动画都强行建模成属性变化。


10.3 布局动画与绘制动画的边界

动画一个布局属性,可能导致整棵相关布局重新计算。例如:

AnimatedContainer(
  height: expanded ? 300 : 100,
  child: child,
)

高度变化会影响父布局和兄弟组件的位置。

而平移通常可以使用绘制阶段的组件:

AnimatedSlide(
  offset: offset,
  duration: const Duration(milliseconds: 300),
  child: child,
)

两者在视觉上都可能表现为“移动”,但布局语义不同:

  • 布局动画:对象的位置或尺寸参与重新布局。
  • 绘制动画:布局结果基本不变,只改变绘制位置或变换。

如果动画对象的变化会导致其他内容重新排版,应接受布局成本并检查溢出;如果只是装饰性移动,优先考虑不改变布局占位的方案。


10.4 大型或复杂子树的性能边界

隐式动画每帧可能导致:

  • build 重新执行。
  • 布局重新计算。
  • 绘制重新执行。
  • 复杂阴影、模糊、裁剪或文本排版重复处理。

这并不表示隐式动画一定性能差,而是说明动画属性应与影响范围匹配。

例如,给一个包含大型列表的祖先容器做尺寸动画,可能使大量内容参与布局;而只对一个图标做 Transform,影响范围通常更小。

诊断时可以:

  • 在 DevTools 的 Performance 页面观察帧耗时。
  • 检查是否出现 UI 线程或 Raster 线程帧超时。
  • 暂时移除阴影、模糊和复杂裁剪,确认瓶颈来自布局还是绘制。
  • RepaintBoundary 分隔适合独立重绘的区域,但不要把它当作无条件的性能修复。

性能问题必须通过实际帧分析确认,不能仅凭组件名称判断。


十一、常见失败表现与诊断方法

11.1 属性变化但没有动画

常见原因包括:

duration 为零

AnimatedContainer(
  duration: Duration.zero,
  width: width,
)

这表示立即到达目标值,不会产生可见过渡。

组件身份被替换

如果每次构建都使用变化的 Key:

AnimatedContainer(
  key: UniqueKey(),
  duration: const Duration(milliseconds: 300),
  width: width,
)

Flutter 可能每次都创建新的状态对象,组件没有机会从旧动画状态继续过渡。

使用了不支持插值的属性

组件可能只对特定属性提供动画。某些自定义对象或不具备合理 lerp 规则的值,只能直接跳变。

实际变化发生在组件外部

例如外层父组件每次都替换了整个页面,内部的隐式动画状态随之丢失。

排查顺序应是:

确认状态确实改变
  ↓
确认 setState 在仍然 mounted 时调用
  ↓
确认 duration 非零
  ↓
确认组件 Element 身份保持
  ↓
确认目标属性支持插值
  ↓
检查父布局是否裁剪或覆盖了动画结果

11.2 AnimatedSwitcher 内容直接替换

首先检查子组件是否有不同的 Key:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: Text(
    value,
    key: ValueKey(value),
  ),
)

如果多个状态最终使用同一个 Key,AnimatedSwitcher 会认为它们是同一个子树,可能只更新属性而不执行切换。

另外,Key 应该稳定地表示内容身份。对同一业务状态反复生成新的 UniqueKey,会导致每次构建都触发切换,造成不必要的动画。


11.3 动画期间出现溢出

例如:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  width: expanded ? 500 : 100,
  child: const Text('一段较长的文本'),
)

在窄屏幕上,目标宽度、文本约束、父组件约束可能互相冲突。动画只是连续地经过中间宽度,并不会自动解决约束问题。

应检查:

  • 父组件提供的最大宽度。
  • 文本是否允许换行。
  • 是否使用了固定宽高。
  • 是否在 Row 中需要 ExpandedFlexible
  • 曲线是否产生了超调。
  • Web、桌面窗口缩放时约束是否发生变化。

开发阶段的 RenderFlex overflowed 警告通常不是动画组件本身的错误,而是动画过程中暴露了原本不稳定的布局约束。


11.4 异步回调导致状态错误

动画通常由状态驱动,而状态可能来自异步任务。以下顺序可能出现问题:

请求 A 发出
请求 B 发出
B 先返回并更新 UI
A 后返回并覆盖 UI

解决办法可以是:

  • 为每次请求分配递增序号。
  • 只接受最新序号的结果。
  • 使用可取消的请求机制。
  • 在回调中检查 mounted
  • 将业务状态和动画状态分开建模。

动画只能展示当前状态的变化,不能保证状态来源本身是正确的。


十二、平台差异

Flutter 的隐式动画 API 由 Flutter 框架统一提供,Android、iOS、桌面和 Web 通常使用相同的 Dart 代码。差异主要出现在动画所作用的渲染环境:

Android 与 iOS

  • 字体、字重和文本度量可能不同。
  • 系统无障碍设置可能表达用户对动画的偏好。
  • 触摸、滚动物理和系统返回手势的交互行为不同。
  • 不同设备刷新率可能不同,动画不应依赖固定帧数。

桌面

  • 窗口可以被用户连续调整大小。
  • 鼠标悬停、焦点切换和键盘操作更常见。
  • 窗口尺寸较大,动画中的布局变化可能影响更多内容。
  • 多窗口和窗口切换可能使动画被暂停、延迟或重新获得调度。

Web

  • 浏览器标签页切后台时,定时器和渲染调度可能被节流。
  • 浏览器窗口尺寸和设备像素比可能动态变化。
  • 文本渲染和字体加载可能导致布局结果与移动端不同。
  • 浏览器环境可能有不同的无障碍和减少动效设置支持方式。

因此,动画时长和曲线在各平台可以复用,但最终视觉效果仍应在目标平台验证。Flutter 保证的是框架 API 和渲染模型,不保证不同平台的字体、窗口约束、刷新调度和光栅化结果完全一致。


十三、减少动效与可访问性

动画可能给部分用户造成不适,或在特定场景中增加认知负担。应用可以根据无障碍环境减少非必要动画。

例如:

final mediaQuery = MediaQuery.of(context);
final disableAnimations = mediaQuery.disableAnimations;

AnimatedContainer(
  duration: disableAnimations
      ? Duration.zero
      : const Duration(milliseconds: 300),
  curve: Curves.easeOut,
  width: expanded ? 300 : 120,
)

需要注意:

  • MediaQuery.disableAnimations 表示当前媒体环境提供的动画禁用偏好。
  • 不同平台和宿主环境对系统设置的传递能力可能不同。
  • 将时长设为零可以避免可见动画,但业务上仍应保证状态直接到达最终值。
  • 不能只删除动画而破坏焦点、语义、交互反馈或布局稳定性。

对于关键状态反馈,不能把动画作为唯一信息来源。例如加载状态、成功状态和错误状态仍应有文本、语义标签或明确控件状态。


十四、隐式动画的选择原则

可以用下面的决策方式判断是否适合隐式动画:

只是某些属性从旧值变化到新值?
  ├─ 是:优先考虑 AnimatedContainer、AnimatedOpacity 等
  └─ 否
      是否需要自定义一个值的插值过程?
        ├─ 是:考虑 TweenAnimationBuilder
        └─ 否
            是否需要精确控制时间轴、手势进度、暂停、反向或循环?
              ├─ 是:使用显式动画
              └─ 否:重新拆分状态和过渡边界

隐式动画最重要的工程边界是:

它适合描述“状态变化后的视觉过渡”,不适合代替复杂的动画编排器、手势进度控制器或业务状态机。

当状态清晰、目标值明确、过渡相对独立时,隐式动画能减少控制器和生命周期代码;当动画本身成为交互逻辑的一部分时,应使用显式动画或更明确的状态模型。

最终可以将隐式动画概括为:

状态目标Tween 插值Curve 时间变换逐帧渲染\text{状态目标} \rightarrow \text{Tween 插值} \rightarrow \text{Curve 时间变换} \rightarrow \text{逐帧渲染}

其中:

  • 状态决定终点。
  • Tween 决定值如何插值。
  • Curve 决定时间如何分配。
  • 隐式组件负责控制器、生命周期和逐帧更新。

理解这四个环节后,AnimatedContainerAnimatedSwitcherTweenAnimationBuilder 的差异就不再只是 API 记忆问题,而可以根据布局、绘制、状态身份和时间控制需求做出明确选择。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。