【Flutter入门练中学】第8课:动画与过渡

目标

  • 理解隐式动画与显式动画的区别与适用场景。
  • 掌握 AnimatedContainer、AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedPositioned、AnimatedCrossFade、AnimatedSwitcher。
  • 掌握 TweenAnimationBuilder 的简化动画写法。
  • 掌握 AnimationController、Tween、CurvedAnimation、AnimatedBuilder、ScaleTransition 的显式动画流程。
  • 掌握 Hero 页面共享元素过渡动画、PageRouteBuilder 自定义页面转场。
  • 能实现点赞心跳、展开卡片、淡入淡出、页面共享元素、进度动画、组合动画。
  • 能识别并修复 AnimationController 未释放、Ticker 相关错误、setState() called after dispose()。
  • 通过"题目要求 → 答案 → 答案解读"的多元化习题完成练中学。

一、知识地图

复制代码
动画与过渡
│
├── 隐式动画(改属性 → 自动补间)
│   ├── AnimatedContainer          宽高 / 颜色 / 圆角 / 边距
│   ├── AnimatedOpacity            透明度
│   ├── AnimatedPadding            内边距
│   ├── AnimatedAlign              对齐位置
│   ├── AnimatedPositioned         Stack 中位置
│   ├── AnimatedDefaultTextStyle   文字样式
│   ├── AnimatedCrossFade          两个子组件之间淡入淡出
│   ├── AnimatedSwitcher           子组件切换
│   └── AnimatedSize               尺寸变化
│
├── 简化显式动画
│   └── TweenAnimationBuilder      自动管理控制器
│
├── 显式动画(手动控制)
│   ├── AnimationController        控制器:play / pause / reverse / repeat
│   ├── Tween                       起止值
│   ├── CurvedAnimation             缓动曲线
│   ├── AnimatedBuilder             监听构建
│   ├── ScaleTransition             缩放
│   ├── FadeTransition              淡入淡出
│   ├── SlideTransition             滑动
│   └── RotationTransition          旋转
│
├── 过渡动画
│   ├── Hero                        页面共享元素
│   └── PageRouteBuilder            自定义页面转场
│
└── 生命周期
    ├── SingleTickerProviderStateMixin   单个控制器
    ├── TickerProviderStateMixin         多个控制器
    └── dispose()                        必须释放

二、快速参考卡

隐式 vs 显式

维度 隐式动画 显式动画
写法 改属性,自动补间 创建 Controller,手动驱动
代表 AnimatedContainer AnimationController
控制 只能设 duration / curve 可暂停、反转、循环、监听状态
适用 状态切换的简单动画 复杂动画、连续动画、交互动画
代码量 少 多
性能 自动优化 需手动优化重建范围

常用隐式动画

Widget 动画属性 典型场景
AnimatedContainer width / height / color / padding / margin / decoration 卡片展开、按钮变色
AnimatedOpacity opacity 淡入淡出
AnimatedPadding padding 内边距过渡
AnimatedAlign alignment 位置切换
AnimatedPositioned left / top / right / bottom Stack 中移动
AnimatedDefaultTextStyle style 文字样式变化
AnimatedCrossFade crossFadeState 两个子组件切换
AnimatedSwitcher child 内容切换
AnimatedSize child 尺寸 高度自适应

AnimationController 常用方法

方法 作用
forward() 从当前值播放到 1.0
reverse() 从当前值反向到 0.0
repeat() 循环播放
repeat(reverse: true) 往返循环
stop() 停止
reset() 重置到 0.0
dispose() 释放(必须)
addStatusListener 监听状态变化
addListener 监听值变化

常用 curve

curve 效果 适用
Curves.linear 匀速 进度条
Curves.easeIn 慢启动 入场
Curves.easeOut 慢结束 出场
Curves.easeInOut 两端慢 通用
Curves.bounceOut 结尾回弹 点赞
Curves.elasticOut 弹性拉伸 强调
Curves.fastOutSlowIn Material 标准 Material 设计

一句话口诀

状态变了想动画,用隐式;想控制播放节奏,用显式;页面跳转共享元素,用 Hero。

隐式与显式对比图

复制代码
隐式:setState 改属性 → AnimatedXxx 自动补间 → 界面平滑变化

显式:AnimationController.forward()
        ↓
      Tween 插值
        ↓
      AnimatedBuilder 重建
        ↓
      界面平滑变化

三、热身:最小可运行示例

dart 复制代码
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: const AnimatedBoxPage());
  }
}

class AnimatedBoxPage extends StatefulWidget {
  const AnimatedBoxPage({super.key});
  @override
  State<AnimatedBoxPage> createState() => _AnimatedBoxPageState();
}

class _AnimatedBoxPageState extends State<AnimatedBoxPage> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('隐式动画')),
      body: Center(
        child: GestureDetector(
          onTap: () => setState(() => _expanded = !_expanded),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 400),
            curve: Curves.easeInOut,
            width: _expanded ? 240 : 120,
            height: _expanded ? 240 : 120,
            decoration: BoxDecoration(
              color: _expanded ? Colors.deepPurple : Colors.blue,
              borderRadius: BorderRadius.circular(_expanded ? 32 : 8),
            ),
            alignment: Alignment.center,
            child: const Text(
              '点我',
              style: TextStyle(color: Colors.white, fontSize: 20),
            ),
          ),
        ),
      ),
    );
  }
}

按 r 热重载,点击方块,观察宽高、颜色、圆角平滑变化。

热身任务 :点击方块 3 次,观察每次变化。然后按 R 热重启,_expanded 回到 false,方块回到初始状态。这说明状态驱动动画:状态变,动画自动补间;状态重置,动画也重置。


四、多元化习题

难度:⭐ 入门 ⭐⭐ 进阶 ⭐⭐⭐ 综合

每题标注【练什么】,帮你明确训练目标。


练习0:热重载体验动画 ⭐

【练什么】duration、curve 对动画的影响

题目要求

运行热身示例,完成以下操作并记录变化:

  1. 把 duration 改成 2 秒,观察动画速度。
  2. 把 curve 改成 Curves.bounceOut,观察回弹效果。
  3. 把 AnimatedContainer 换成普通 Container,观察差异。

答案

dart 复制代码
// 1. 变慢
duration: const Duration(seconds: 2),

// 2. 回弹曲线
curve: Curves.bounceOut,

// 3. 普通 Container 直接跳变
Container(
  width: _expanded ? 240 : 120,
  height: _expanded ? 240 : 120,
  decoration: BoxDecoration(
    color: _expanded ? Colors.deepPurple : Colors.blue,
    borderRadius: BorderRadius.circular(_expanded ? 32 : 8),
  ),
  alignment: Alignment.center,
  child: const Text('点我', style: TextStyle(color: Colors.white, fontSize: 20)),
)

答案解读

  • duration 控制动画时长。时间越长,补间越慢;时间越短,变化越快。
  • curve 控制缓动曲线。easeInOut 是两端慢中间快,bounceOut 是结尾回弹,linear 是匀速。
  • AnimatedContainer 在属性变化时自动补间;普通 Container 会瞬间跳变。

隐式动画的核心:属性变化 + 自动补间。你只需要改状态,Flutter 负责插值和渲染。

为什么 curve 能让动画更自然?

真实世界的运动不是匀速的。物体启动时加速,停止时减速。Curves 模拟这种物理规律,让动画更符合直觉。bounceOut 模拟弹跳落地,elasticOut 模拟弹簧。


练习1:选择题 ⭐

【练什么】隐式动画的识别

题目要求

以下哪个 Widget 属于隐式动画?

A. AnimationController

B. AnimatedOpacity

C. Tween

D. CurvedAnimation

答案

B。

答案解读

  • AnimatedOpacity 是隐式动画,改变 opacity 值后自动补间。
  • AnimationController、Tween、CurvedAnimation 都是显式动画的组成部分,需要手动驱动。

识别技巧 :名字以 Animated 开头的 Widget 几乎都是隐式动画。AnimatedContainer、AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedSwitcher、AnimatedCrossFade、AnimatedSize、AnimatedPositioned、AnimatedDefaultTextStyle。


练习2:判断题 ⭐

【练什么】动画概念边界

题目要求

判断对错,并说明理由:

  1. AnimatedContainer 会在 width、height、color 变化时自动动画。
  2. AnimationController 不需要释放。
  3. Hero 用于页面间共享元素过渡。
  4. TweenAnimationBuilder 需要手动创建 AnimationController。
  5. AnimationController 的默认值范围是 0 到 1。

答案

  1. 对。
  2. 错。
  3. 对。
  4. 错。
  5. 对。

答案解读

  • AnimatedContainer 会自动对可动画属性做补间。
  • AnimationController 必须在 dispose() 中释放,否则会泄漏。
  • Hero 用于两个页面之间共享同一个 Widget 的过渡动画。
  • TweenAnimationBuilder 内部自动管理控制器,无需手动创建。
  • AnimationController 默认值范围是 0 到 1,可以直接用作 opacity、scale 等。

为什么 AnimationController 必须释放?

它持有 Ticker,Ticker 会向系统注册每帧回调。不释放会导致页面销毁后仍在消耗资源,触发内存泄漏警告。记住:创建了 AnimationController,就必须在 dispose() 中释放。


练习3:连线题 ⭐

【练什么】隐式动画组件与作用对应

题目要求

将左侧组件与右侧作用连线:

左侧 右侧
AnimatedOpacity 内边距动画
AnimatedPadding 透明度动画
AnimatedAlign 页面共享元素
AnimatedSwitcher 对齐位置动画
Hero 子组件切换动画

答案

  • AnimatedOpacity --- 透明度动画
  • AnimatedPadding --- 内边距动画
  • AnimatedAlign --- 对齐位置动画
  • AnimatedSwitcher --- 子组件切换动画
  • Hero --- 页面共享元素

答案解读

隐式动画组件命名规律是 Animated + 属性名,作用就是对该属性做补间。Hero 属于过渡动画,跨页面生效。

更多隐式动画:

  • AnimatedPositioned:Stack 中位置变化。
  • AnimatedDefaultTextStyle:文字样式变化。
  • AnimatedCrossFade:两个子组件之间淡入淡出。
  • AnimatedSize:尺寸变化。

练习4:排序题 ⭐⭐

【练什么】显式动画的创建流程

题目要求

实现一个显式动画,按正确顺序排列:

在 initState 中创建并启动 controller

在 dispose 中释放 controller

创建 AnimationController 并指定 vsync

用 AnimatedBuilder 监听并构建界面

答案

创建 AnimationController 并指定 vsync → 在 initState 中创建并启动 controller → 用 AnimatedBuilder 监听并构建界面 → 在 dispose 中释放 controller

答案解读

显式动画四步:创建、启动、监听、释放。顺序不能乱。

为什么需要 vsync?

vsync 是 TickerProvider 的缩写,它让 AnimationController 与屏幕刷新率同步。SingleTickerProviderStateMixin 提供了一个 Ticker,让动画每帧更新。

为什么用 SingleTickerProviderStateMixin?

  • 一个页面只有一个 AnimationController 时用 SingleTickerProviderStateMixin。
  • 有多个 AnimationController 时用 TickerProviderStateMixin。
  • 不混入的话,vsync 参数无法提供,编译报错。

练习5:填空题 ⭐

【练什么】淡入淡出

题目要求

补全代码,实现淡入淡出:

dart 复制代码
AnimatedOpacity(
  opacity: _visible ? ______ : ______,
  duration: const Duration(milliseconds: 300),
  child: const Text('我会淡入淡出'),
)

答案

dart 复制代码
AnimatedOpacity(
  opacity: _visible ? 1.0 : 0.0,
  duration: const Duration(milliseconds: 300),
  child: const Text('我会淡入淡出'),
)

答案解读

  • opacity 取值 0 到 1。0 表示完全透明,1 表示完全不透明。
  • 切换 _visible 后,AnimatedOpacity 会在 300 毫秒内平滑过渡透明度。
  • 这是最常用的隐式动画之一,适合做显示/隐藏、淡入/淡出效果。

注意 :opacity 为 0 时,子组件仍然占据空间,只是不可见。如果要同时移除空间,用 AnimatedSwitcher 或 Visibility。


练习6:代码预测题 ⭐⭐

【练什么】显式动画的连续播放

题目要求

阅读代码,预测动画效果:

dart 复制代码
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    )..repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _controller,
      child: const FlutterLogo(size: 100),
    );
  }
}

答案

Flutter Logo 会持续放大缩小,1 秒一个来回,无限循环。

答案解读

  • repeat(reverse: true) 让动画反复播放并反向。
  • ScaleTransition 监听 _controller 的值并缩放子组件。
  • AnimationController 默认值范围 0 到 1,直接用作 scale 就是 0 到 1 倍缩放。

注意 :scale: _controller 时,scale 值从 0 到 1。如果想让 scale 从 1 到 1.4,需要配合 Tween:

dart 复制代码
_controller = AnimationController(
  duration: const Duration(milliseconds: 300),
  vsync: this,
);
final _scale = Tween(begin: 1.0, end: 1.4).animate(
  CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);

// 使用
ScaleTransition(scale: _scale, child: ...)

练习7:改错题 ⭐⭐

【练什么】显式动画的常见错误

题目要求

下面代码有错误,找出来并修正:

dart 复制代码
class _DemoState extends State<Demo> {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
    );
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _controller,
      child: const Text('淡入'),
    );
  }
}

答案

修正后:

dart 复制代码
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    )..forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _controller,
      child: const Text('淡入'),
    );
  }
}

答案解读

三个错误:

  1. 缺少 vsync: this :需要混入 SingleTickerProviderStateMixin,否则 vsync 参数无法提供。
  2. 没有释放控制器 :必须在 dispose() 中调用 _controller.dispose()。
  3. 没有启动动画 :AnimationController 创建后默认停在 0.0,需要调用 forward() 才会播放。

修正步骤 :混入 Mixin → 加 vsync: this → 加 ..forward() → 加 dispose()。


练习8:补全代码题 ⭐⭐

【练什么】TweenAnimationBuilder 数字动画

题目要求

用 TweenAnimationBuilder 实现数字从 0 平滑增长到 100:

dart 复制代码
TweenAnimationBuilder<double>(
  tween: Tween(begin: ______, end: ______),
  duration: const Duration(seconds: 2),
  builder: (context, value, child) {
    return Text('${value.round()}', style: const TextStyle(fontSize: 48));
  },
)

答案

dart 复制代码
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: 100),
  duration: const Duration(seconds: 2),
  builder: (context, value, child) {
    return Text('${value.round()}', style: const TextStyle(fontSize: 48));
  },
)

答案解读

  • TweenAnimationBuilder 内部自动创建控制器。
  • tween 定义起止值,builder 中拿到当前插值 value 构建界面。
  • 改 end 值即可重新动画。

为什么比显式动画简单?

不需要手动创建 AnimationController、不需要 vsync、不需要 dispose。适合一次性动画、数值过渡、进度条等场景。

如果想让数字在点击时重新动画:

dart 复制代码
double _target = 0;

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: _target),
  duration: const Duration(seconds: 2),
  builder: (context, value, child) => Text('${value.round()}'),
)

// 点击按钮
onPressed: () => setState(() => _target = 100),

练习9:动手编码题:点赞心跳 ⭐⭐

【练什么】AnimationController + Tween + ScaleTransition

题目要求

做一个点赞按钮,点击时爱心图标放大再回弹,同时显示点赞数。

答案

dart 复制代码
class LikeButton extends StatefulWidget {
  const LikeButton({super.key});

  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scale;
  bool _liked = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _scale = Tween(begin: 1.0, end: 1.4).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _controller.reverse();
      }
    });
  }

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

  void _onTap() {
    setState(() => _liked = !_liked);
    _controller.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _onTap,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ScaleTransition(
            scale: _scale,
            child: Icon(
              _liked ? Icons.favorite : Icons.favorite_border,
              color: _liked ? Colors.red : Colors.grey,
              size: 48,
            ),
          ),
          const SizedBox(height: 8),
          Text(_liked ? '已点赞' : '点赞'),
        ],
      ),
    );
  }
}

答案解读

  • AnimationController 驱动 Tween(1.0 → 1.4),ScaleTransition 把缩放应用到图标上。
  • 状态监听在动画完成后反向播放,形成回弹效果。
  • 点击时切换 _liked 并从头播放动画。

关键点:

  • _controller.forward(from: 0) 让动画从 0 开始,而不是从当前位置继续。
  • addStatusListener 监听 AnimationStatus.completed,动画完成时触发反向。
  • reverse() 会让动画从 1.4 回到 1.0,形成回弹。

练习10:动手编码题:Hero 页面过渡 ⭐⭐

【练什么】Hero 跨页面共享元素

题目要求

做一个列表页和详情页。列表页点击某张卡片时,用 Hero 让卡片平滑过渡到详情页顶部。

答案

dart 复制代码
// 列表页
class HeroListPage extends StatelessWidget {
  const HeroListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('列表')),
      body: ListView.builder(
        itemCount: 5,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => HeroDetailPage(index: index)),
            ),
            child: Hero(
              tag: 'item_$index',
              child: Container(
                height: 80,
                margin: const EdgeInsets.all(8),
                color: Colors.primaries[index % Colors.primaries.length],
                alignment: Alignment.center,
                child: Text('第 $index 项',
                    style: const TextStyle(color: Colors.white)),
              ),
            ),
          );
        },
      ),
    );
  }
}

// 详情页
class HeroDetailPage extends StatelessWidget {
  final int index;
  const HeroDetailPage({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('详情 $index')),
      body: Hero(
        tag: 'item_$index',
        child: Container(
          height: 240,
          color: Colors.primaries[index % Colors.primaries.length],
          alignment: Alignment.center,
          child: Text('第 $index 项',
              style: const TextStyle(color: Colors.white, fontSize: 24)),
        ),
      ),
    );
  }
}

答案解读

  • Hero 的 tag 在两个页面中必须一致,Flutter 会自动匹配并做过渡。
  • 列表项和详情页的 Hero 子组件可以不同,但 tag 必须相同。
  • 这是页面过渡最常用的效果。

Hero 工作原理:

  1. 用户点击列表项,触发 Navigator.push。
  2. Flutter 在当前页面找到 tag 匹配的 Hero,记录其位置和大小。
  3. 在新页面找到相同 tag 的 Hero,记录其目标位置和大小。
  4. Flutter 在过渡期间创建一个"飞行"的 Hero,从起始位置动画到目标位置。

常见错误:

  • tag 不匹配:两个页面 tag 不一致,Hero 不生效。
  • tag 重复 :同一页面有多个相同 tag 的 Hero,报 Multiple heroes share the same tag。
  • Hero 包裹了非 Material 组件 :文字可能没有样式,用 Material 包裹。

练习11:综合挑战:可展开卡片 ⭐⭐⭐

【练什么】AnimatedCrossFade + 点击展开

题目要求

做一个可展开的卡片:点击标题时,卡片平滑展开显示更多内容;再次点击收起。

答案

dart 复制代码
class ExpandableCard extends StatefulWidget {
  const ExpandableCard({super.key});

  @override
  State<ExpandableCard> createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: InkWell(
        onTap: () => setState(() => _expanded = !_expanded),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text('点击展开',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              AnimatedCrossFade(
                duration: const Duration(milliseconds: 300),
                crossFadeState: _expanded
                    ? CrossFadeState.showSecond
                    : CrossFadeState.showFirst,
                firstChild: const SizedBox.shrink(),
                secondChild: const Padding(
                  padding: EdgeInsets.only(top: 12),
                  child: Text(
                    '这里是展开后的详细内容。'
                    'AnimatedCrossFade 会在两个子组件之间做淡入淡出过渡。',
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

答案解读

  • AnimatedCrossFade 在两个子组件之间做淡入淡出。
  • crossFadeState 控制显示哪个。
  • SizedBox.shrink() 作为收起时的空内容。
  • 配合 InkWell 点击切换状态,就能实现平滑展开收起。

AnimatedSize 对比:

dart 复制代码
AnimatedSize(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  child: _expanded
      ? const Text('展开后的内容')
      : const SizedBox.shrink(),
)
  • AnimatedCrossFade:两个子组件之间切换,适合"有内容 / 无内容"。
  • AnimatedSize:子组件尺寸变化时自动补间,适合"高度变化"。

练习12:开放式挑战:加载进度动画 ⭐⭐⭐

【练什么】AnimationController + Tween + AnimatedBuilder

题目要求

用 AnimationController 和 Tween 实现一个圆形进度动画:从 0 到 1 播放 3 秒,中间显示百分比,结束后显示"完成"。

答案示例

dart 复制代码
class ProgressDemo extends StatefulWidget {
  const ProgressDemo({super.key});

  @override
  State<ProgressDemo> createState() => _ProgressDemoState();
}

class _ProgressDemoState extends State<ProgressDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _progress;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    );
    _progress = Tween(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('进度动画')),
      body: Center(
        child: AnimatedBuilder(
          animation: _progress,
          builder: (context, child) {
            final value = _progress.value;
            return Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                SizedBox(
                  width: 160,
                  height: 160,
                  child: CircularProgressIndicator(
                    value: value,
                    strokeWidth: 10,
                  ),
                ),
                const SizedBox(height: 16),
                Text(
                  value >= 1.0 ? '完成' : '${(value * 100).round()}%',
                  style: const TextStyle(fontSize: 24),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

答案解读

  • AnimationController 驱动 Tween(0 → 1),AnimatedBuilder 监听变化重建。
  • CircularProgressIndicator 的 value 接收进度值。
  • value >= 1.0 时显示"完成"。
  • 这是自定义进度动画的标准写法。

为什么用 AnimatedBuilder 而不是 setState?

  • AnimatedBuilder 只重建 builder 内的子树,性能更好。
  • setState 会重建整个 Widget,如果页面复杂,性能会下降。
  • AnimatedBuilder 是显式动画的标准写法。

练习13:错误观察挑战 ⭐⭐

【练什么】AnimationController 未释放

题目要求

故意制造一次 AnimationController 未释放的错误:创建控制器但不写 dispose(),然后频繁切换页面。观察控制台提示,记录关键错误词。

答案

示例错误代码:

dart 复制代码
class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    )..repeat();
  }

  // 缺少 dispose

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

控制台常见错误或警告:

复制代码
AnimationController.dispose() called more than once.

或:

复制代码
A Ticker was active when its widget was disposed.

或内存泄漏警告。

答案解读

  • AnimationController 持有 Ticker,如果不释放,页面销毁后 Ticker 仍在运行,造成资源泄漏。
  • 正确做法是在 dispose() 中调用 _controller.dispose()。
  • 如果动画还在运行,先 stop() 再 dispose() 更安全。

标准写法:

dart 复制代码
@override
void dispose() {
  _controller.stop();     // 可选:先停止
  _controller.dispose();  // 必须:释放
  super.dispose();
}

练习14:进阶挑战:组合动画 ⭐⭐⭐

【练什么】多个 Tween 共享一个 Controller

题目要求

做一个卡片,点击时同时执行:缩放(1.0 → 1.1)、透明度(0.5 → 1.0)、旋转(0 → 0.1 弧度)。三个动画同时播放 500 毫秒。

答案

dart 复制代码
class ComboAnimationPage extends StatefulWidget {
  const ComboAnimationPage({super.key});
  @override
  State<ComboAnimationPage> createState() => _ComboAnimationPageState();
}

class _ComboAnimationPageState extends State<ComboAnimationPage>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 500),
      vsync: this,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    final scale = Tween(begin: 1.0, end: 1.1).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );
    final opacity = Tween(begin: 0.5, end: 1.0).animate(_controller);
    final rotation = Tween(begin: 0.0, end: 0.1).animate(_controller);

    return Scaffold(
      appBar: AppBar(title: const Text('组合动画')),
      body: Center(
        child: GestureDetector(
          onTap: () => _controller.forward(from: 0),
          child: AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              return Opacity(
                opacity: opacity.value,
                child: Transform.rotate(
                  angle: rotation.value,
                  child: Transform.scale(
                    scale: scale.value,
                    child: Container(
                      width: 200,
                      height: 200,
                      color: Colors.deepPurple,
                      alignment: Alignment.center,
                      child: const Text(
                        '点我',
                        style: TextStyle(color: Colors.white, fontSize: 24),
                      ),
                    ),
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

答案解读

  • 一个 AnimationController 可以驱动多个 Tween。
  • 每个 Tween 用 animate(_controller) 绑定控制器。
  • AnimatedBuilder 监听控制器,同时更新透明度、旋转、缩放。

性能优化 :把不依赖动画的子树作为 child 传入,避免重复构建:

dart 复制代码
AnimatedBuilder(
  animation: _controller,
  child: Container(width: 200, height: 200, color: Colors.deepPurple),
  builder: (context, child) {
    return Transform.scale(scale: scale.value, child: child);
  },
)

练习15:进阶挑战:Hero + 自定义过渡 ⭐⭐⭐

【练什么】Hero 配合 PageRouteBuilder

题目要求

在 Hero 过渡的基础上,让详情页从右侧滑入,而不是默认的底部弹入。

答案

dart 复制代码
Navigator.push(
  context,
  PageRouteBuilder(
    transitionDuration: const Duration(milliseconds: 400),
    pageBuilder: (context, animation, secondaryAnimation) {
      return HeroDetailPage(index: index);
    },
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final offsetTween = Tween(
        begin: const Offset(1, 0),
        end: Offset.zero,
      ).chain(CurveTween(curve: Curves.easeOutCubic));

      return SlideTransition(
        position: animation.drive(offsetTween),
        child: child,
      );
    },
  ),
);

答案解读

  • PageRouteBuilder 让你完全控制转场动画。
  • pageBuilder 返回目标页面,transitionsBuilder 定义过渡效果。
  • Offset(1, 0) 表示从右侧开始,Offset.zero 表示最终位置。
  • CurveTween 给动画加缓动曲线。

常见过渡效果:

效果 起始 Offset 方向
底部滑入 Offset(0, 1) 从下到上
右侧滑入 Offset(1, 0) 从右到左
左侧滑入 Offset(-1, 0) 从左到右
顶部滑入 Offset(0, -1) 从上到下

淡入 + 滑动组合:

dart 复制代码
transitionsBuilder: (context, animation, secondaryAnimation, child) {
  return FadeTransition(
    opacity: animation,
    child: SlideTransition(
      position: animation.drive(
        Tween(begin: const Offset(0, 0.1), end: Offset.zero)
            .chain(CurveTween(curve: Curves.easeOut)),
      ),
      child: child,
    ),
  );
},

五、综合小项目:动画卡片列表

目标:用本课知识做一个动画卡片列表,包含:

  • 列表项进入时淡入 + 从下往上滑入;
  • 点击卡片,卡片缩放并跳转到详情页;
  • 详情页使用 Hero 过渡;
  • 详情页有一个点赞按钮,点击时心跳动画;
  • 返回列表时,列表项淡出。

参考答案框架:

dart 复制代码
class AnimatedListPage extends StatefulWidget {
  const AnimatedListPage({super.key});
  @override
  State<AnimatedListPage> createState() => _AnimatedListPageState();
}

class _AnimatedListPageState extends State<AnimatedListPage>
    with SingleTickerProviderStateMixin {
  late AnimationController _entryController;

  @override
  void initState() {
    super.initState();
    _entryController = AnimationController(
      duration: const Duration(milliseconds: 600),
      vsync: this,
    )..forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('动画卡片列表')),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) {
          final delay = index * 0.05;
          final animation = CurvedAnimation(
            parent: _entryController,
            curve: Interval(
              delay.clamp(0.0, 0.5),
              (delay + 0.5).clamp(0.0, 1.0),
              curve: Curves.easeOut,
            ),
          );

          return FadeTransition(
            opacity: animation,
            child: SlideTransition(
              position: animation.drive(
                Tween(begin: const Offset(0, 0.2), end: Offset.zero),
              ),
              child: GestureDetector(
                onTap: () => Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (_) => DetailPage(index: index),
                  ),
                ),
                child: Hero(
                  tag: 'card_$index',
                  child: Card(
                    margin: const EdgeInsets.all(8),
                    child: ListTile(
                      title: Text('卡片 $index'),
                      subtitle: const Text('点击查看详情'),
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

答案解读

这个项目综合了本课所有知识点:隐式动画、显式动画、Hero、Tween、CurvedAnimation、Interval。建议先自己动手写,再对照参考框架。

关键技巧:

  • Interval 让不同列表项错开动画时间,形成瀑布式入场效果。
  • delay.clamp(0.0, 0.5) 确保 Interval 的起始值不超过 0.5。
  • Hero 和 GestureDetector 配合,点击卡片时既有缩放又有页面过渡。

六、本课小结

你练了什么

  1. 隐式动画:AnimatedContainer、AnimatedOpacity、AnimatedCrossFade、AnimatedSwitcher、AnimatedSize;
  2. 显式动画:AnimationController、Tween、CurvedAnimation、AnimatedBuilder、ScaleTransition、FadeTransition、SlideTransition;
  3. 简化动画:TweenAnimationBuilder;
  4. 过渡动画:Hero 页面共享元素、PageRouteBuilder 自定义转场;
  5. 点赞心跳、展开卡片、Hero 跳转、进度动画、组合动画、瀑布式入场;
  6. 通过故意不释放 AnimationController,学会看 Ticker 相关错误和内存泄漏警告。

核心心法

状态变了想动画,用隐式;想控制播放节奏,用显式;页面跳转共享元素,用 Hero。

隐式 vs 显式选择指南

场景 推荐
点击切换颜色、大小、透明度 隐式
加载进度、循环动画 显式
数字从 A 到 B TweenAnimationBuilder
页面间共享元素 Hero
自定义页面转场 PageRouteBuilder
多个属性同时动画 显式 + 多个 Tween

常见错误速查

错误信息 原因 解决
AnimationController.dispose() called more than once 重复释放 只在 State 的 dispose 中调用一次
A Ticker was active when its widget was disposed 未释放控制器 加 _controller.dispose()
vsync was null 缺少 TickerProvider 混入 SingleTickerProviderStateMixin
Multiple heroes share the same tag Hero tag 冲突 每个 Hero tag 唯一
setState() called after dispose() 异步回调中页面已销毁 加 if (mounted)
动画不播放 没调用 forward / repeat 检查是否启动动画
动画跳变 用了普通 Container 换成 AnimatedContainer

性能优化建议

优化点 做法
减少重建范围 用 AnimatedBuilder 只重建动画部分
复用子树 AnimatedBuilder 的 child 参数传入不变部分
避免动画中做重活 动画回调中不做网络、存储操作
及时释放 dispose() 中释放控制器
合理使用隐式 简单动画用隐式,不必上显式
控制动画时长 一般 200--500ms,过短看不清,过长卡顿感
使用 RepaintBoundary 复杂动画包裹,减少重绘范围

面试常见问题

  1. 隐式动画和显式动画有什么区别?

    隐式动画改属性自动补间,代码少,适合简单切换;显式动画需要手动创建 AnimationController,可以控制播放、暂停、反转、循环,适合复杂动画。

  2. AnimationController 为什么需要 vsync?

    vsync 让动画与屏幕刷新率同步,避免不必要的帧渲染。通过 SingleTickerProviderStateMixin 提供。

  3. Hero 的 tag 有什么要求?

    两个页面的 Hero tag 必须一致,且同一页面不能有重复 tag,否则报 Multiple heroes share the same tag。

  4. AnimatedBuilder 和 setState 哪个更适合动画?

    AnimatedBuilder 只重建 builder 内的子树,性能更好。setState 会重建整个 Widget,动画场景下不推荐。

  5. 如何在动画结束后执行操作?

    用 _controller.addStatusListener 监听 AnimationStatus.completed,或用 await _controller.forward() 等动画完成。

  6. Tween 和 CurvedAnimation 有什么区别?

    Tween 定义起止值(从 A 到 B),CurvedAnimation 定义缓动曲线(怎么从 A 到 B)。两者结合使用:Tween(begin, end).animate(CurvedAnimation(parent, curve))。

下一课预告

自定义组件与主题------ThemeData、InheritedWidget、CustomPainter、自定义 Widget、组合优于继承,继续用"练中学"的方式做可复用组件与统一主题。

相关推荐
程序员yu2 小时前
会编网络:别被入门教程骗了,Python实用能力不在语法本身
开发语言·python·学习·算法
Lintongzg2 小时前
千卡训练的隐形账单:通信不是瓶颈
笔记·学习·性能优化·llm
dadaobusi2 小时前
学习: SIOV
学习
LuminousCPP2 小时前
从零开始学 C++(三):类和对象入门|从 struct 到 class,理解封装、对象与 this 指针
c++·笔记·学习·类和对象
传奇开心果编程3 小时前
【Flutter入门练中学】第11课:状态管理进阶与声明式路由
android·学习·flutter·ui·ios
承渊政道3 小时前
Linux系统学习【进程信号详细解析——认识、产生、保存以及捕捉信号】
linux·学习·ubuntu·ssh·vs code·进程信号
JasonSJX4 小时前
四端自建播放器怎么落地:Android、iOS、tvOS、Tizen 的 DRM 播放 SDK 技术盘点
android·ios·音视频·视频防录屏·加密保护课程·直播安全
陆断枫4 小时前
Flutter 列表性能优化
flutter
驰骋工作流4 小时前
工作流引擎四大流程模块功能点统计:769 项能力清单梳理低代码工作流引擎表单
android·低代码·rxjava