CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Flutter 开发入门/课时/动画
RawBlame
课时目录12 节

Flutter 开发入门

  1. 01Flutter 环境
  2. 02Dart 语言
  3. 03Widget 体系
  4. 04架构原则
  5. 05状态管理
  6. 06动画
  7. 07网络与本地存储
  8. 08Firebase 集成
  9. 09测试
  10. 10CI/CD 与发布
  11. 11分析与监控
  12. 12综合练习

动画

Controller、Hero 与曲线。

30 分钟

动画

这一课你将能做到

  1. 用 AnimatedContainer 等隐式动画 Widget 一行实现属性过渡
  2. 用 AnimationController + Tween + AnimatedBuilder 写显式动画,并控制曲线、时长、重复
  3. 实现 Hero 跨页面共享元素动画,以及交错(Staggered)动画

为什么要学

现代 App 的「高级感」很大程度来自动画。Flutter 动画体系分两层:隐式动画(简单属性变化)和显式动画(自己控制 AnimationController)。不会显式动画,就只能做静态页面。

人话:隐式动画像「自动变速」,告诉它终点就行;显式动画像「手动挡」,你自己控制时间和曲线,能力更强但要管更多。

先建立图像

隐式动画                          显式动画
AnimatedContainer                AnimationController (时钟,0→1)
AnimatedOpacity                       │
AnimatedPositioned                    ▼
   ↓                                Tween (范围映射:0→1 映射到 100→200)
内部自动补间                            │
                                      ▼
                                 Animation<double> (当前值)
                                      │
                                      ▼
                                 AnimatedBuilder / addListener → setState

跟着做

步骤 1:隐式动画 5 分钟上手

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: ImplicitDemo()));

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

class _ImplicitDemoState extends State<ImplicitDemo> {
  bool _big = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: GestureDetector(
          onTap: () => setState(() => _big = !_big),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 400),
            curve: Curves.easeInOut,
            width: _big ? 240 : 120,
            height: _big ? 240 : 120,
            decoration: BoxDecoration(
              color: _big ? Colors.orange : Colors.blue,
              borderRadius: BorderRadius.circular(_big ? 60 : 12),
            ),
            child: const Icon(Icons.favorite, color: Colors.white, size: 48),
          ),
        ),
      ),
    );
  }
}

点一下,方块在 400ms 内平滑变大、变色、变圆。只改字段值,框架自动补间。

常用隐式动画 Widget:AnimatedOpacity、AnimatedPadding、AnimatedAlign、AnimatedPositioned、AnimatedSwitcher、AnimatedCrossFade、。

AnimatedDefaultTextStyle

步骤 2:显式动画三件套

显式动画 = AnimationController(时钟) + Tween(取值范围) + AnimatedBuilder(重建)。

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

class _SpinDemoState extends State<SpinDemo> with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;
  late final Animation<double> _rotation;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat();                                    // 循环
    _rotation = Tween<double>(begin: 0, end: 6.283).animate(
      CurvedAnimation(parent: _ctrl, curve: Curves.linear),
    );
  }

  @override
  void dispose() {
    _ctrl.dispose();                                // 必须 dispose
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: AnimatedBuilder(
          animation: _rotation,
          builder: (ctx, child) =>
              Transform.rotate(angle: _rotation.value, child: child),
          child: const Icon(Icons.refresh, size: 64, color: Colors.blue),
        ),
      ),
    );
  }
}

要点:

  • with SingleTickerProviderStateMixin 给 State 提供 vsync,避免离屏动画浪费帧
  • CurvedAnimation 包一层可以得到非线性曲线
  • AnimatedBuilder 的 child 参数:不变的子树通过 child 传入,避免每帧重建

步骤 3:动画控制 — 前进 / 反转 / 停止

_ctrl.forward();          // 0 → 1
_ctrl.reverse();          // 1 → 0
_ctrl.stop();
_ctrl.reset();            // 回到 0
_ctrl.repeat(reverse: true);   // 来回循环
_ctrl.animateTo(0.6);          // 跑到指定值

// 监听状态
_ctrl.addStatusListener((s) {
  if (s == AnimationStatus.completed) _ctrl.reverse();
  if (s == AnimationStatus.dismissed) _ctrl.forward();
});

步骤 4:Hero 跨页面动画

// 列表页
class ListPage extends StatelessWidget {
  const ListPage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('列表')),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (ctx, i) => ListTile(
          leading: Hero(
            tag: 'avatar-$i',
            child: CircleAvatar(child: Text('$i')),
          ),
          title: Text('用户 $i'),
          onTap: () => Navigator.of(context).push(
            MaterialPageRoute(builder: (_) => DetailPage(index: i)),
          ),
        ),
      ),
    );
  }
}

// 详情页
class DetailPage extends StatelessWidget {
  final int index;
  const DetailPage({super.key, required this.index});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: Hero(
          tag: 'avatar-$index',                        // 同一个 tag
          child: CircleAvatar(radius: 80, child: Text('$index', style: const TextStyle(fontSize: 48))),
        ),
      ),
    );
  }
}

规则:两个页面的 Hero 用同一个 tag,框架自动过渡。可以在 Hero 外套 Material 避免文字下划线闪烁。

步骤 5:交错动画(Staggered)

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

class _StaggeredDemoState extends State<StaggeredDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;

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

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

  Animation<double> _item(double begin, double end) =>
      Tween<double>(begin: 0, end: 1).animate(
        CurvedAnimation(
          parent: _ctrl,
          curve: Interval(begin, end, curve: Curves.easeOut),
        ),
      );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            for (var i = 0; i < 4; i++)
              FadeTransition(
                opacity: _item(i * 0.2, i * 0.2 + 0.4),
                child: SlideTransition(
                  position: Tween<Offset>(
                    begin: const Offset(0, 0.3),
                    end: Offset.zero,
                  ).animate(CurvedAnimation(
                    parent: _ctrl,
                    curve: Interval(i * 0.2, i * 0.2 + 0.4, curve: Curves.easeOut),
                  )),
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: CircleAvatar(radius: 24, child: Text('$i')),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

Interval(begin, end) 把 controller 的 0-1 切成不同时间窗,让一个 controller 驱动多个动画错开播放。

步骤 6:页面切换过渡

Navigator.of(context).push(
  PageRouteBuilder(
    transitionDuration: const Duration(milliseconds: 350),
    pageBuilder: (_, __, ___) => const DetailPage(index: 0),
    transitionsBuilder: (_, animation, __, child) {
      return FadeTransition(
        opacity: animation,
        child: SlideTransition(
          position: Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero)
              .animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
          child: child,
        ),
      );
    },
  ),
);

讲清楚要点

  1. 隐式 vs 显式:属性变化简单 → 隐式;要循环、要精细控制、要多动画编排 → 显式。
  2. AnimationController 是心脏:vsync 提供帧同步,duration 决定速度;范围默认 0.0-1.0。
  3. Tween 把 0-1 映射到任意值:Tween<double>(0, 200)、ColorTween(begin: red, end: blue)。
  4. Curves 决定「手感」:Curves.easeOut(先快后慢,最常见)、Curves.elasticOut(弹性)、Curves.bounceOut。
  5. 必须 dispose controller:忘记 dispose 是常见内存泄漏源。

你可能会懵的地方

  • vsync: this 是干嘛的? this 来自 SingleTickerProviderStateMixin,提供 Ticker,让动画跟着屏幕刷新率走,并且在页面不可见时暂停省电。

  • SingleTicker 和 Ticker 区别? 一个 State 同时只有一个 controller 用 Single;多个用 TickerProviderStateMixin。

  • AnimatedBuilder vs setState 哪个好? AnimatedBuilder 只重建 builder 内的子树,性能更好;用 addListener+setState 会重建整个 State 的 build。

  • 为什么 Hero 文字会有下划线黄条? 详情页没有 Material 包裹文字。在 Hero 外套一层 Material(color: Colors.transparent, child: ...)。

  • 动画卡顿怎么排查? DevTools → Performance,看每帧是否超 16ms。常见原因:动画 builder 里构建大型子树(用 child 缓存)、paint 太复杂(用 RepaintBoundary)。

错法对照

错法问题正解
AnimationController 在 build 里创建每次 rebuild 都新建,内存泄漏initState 创建,dispose 销毁
忘记 _ctrl.dispose()内存泄漏,发热State.dispose 里 dispose controller
用 addListener + setState 写所有动画整个 build 每帧重建用 AnimatedBuilder 圈小重建范围
AnimatedBuilder 的 builder 内部 new 大量 Widget每帧重建所有子 Widget不变化的子树通过 child 参数传入
Hero 的 tag 用 index 当字符串跨页面/跨列表 tag 冲突tag 加上业务 id,保证全局唯一
一个 controller 在多个 State 用 vsync: this多个 vsync 来源冲突每个 State 自己的 controller

卡点急救

  1. 动画不播放:_ctrl.forward() 调了吗?vsync 传了吗?
  2. Ticker was disposed but was scheduled:dispose 后 controller 还在被调用。确保 dispose() 顺序:先 _ctrl.dispose() 再 super.dispose()。
  3. 曲线没效果:CurvedAnimation 必须包在 Tween.animate(...) 里面。
  4. Hero 跳变不流畅:两个页面的 Hero child 尺寸差太多;用 flightShuttleBuilder 自定义过渡。
  5. setState() called after dispose():异步回调里调用 setState 之前 if (!mounted) return;。

面试常问

  • 隐式动画和显式动画区别? 隐式动画(AnimatedContainer 等)内部封装了 controller,只需改字段;显式动画自己持有 AnimationController,可以精确控制播放、重复、反转、组合多个动画。

  • AnimationController 与 Tween 的关系? Controller 提供 0-1 的时间进度;Tween 把 0-1 映射到目标值域(位置、颜色、大小)。Tween.animate(controller) 把它们组合成一个 Animation。

  • Hero 动画原理? 路由切换时,框架找到源页面和目标页面 tag 相同的 Hero,把它的 RenderObject 从原树摘下,在两个页面之间做一次 overlay 动画过渡到目标位置和大小。

  • 怎么实现一个呼吸灯效果? AnimationController(duration: 1s)..repeat(reverse: true) 驱动 Tween<double>(0.5, 1.0),绑定到 Opacity。

自测三问

  1. AnimationController 默认输出范围是多少?怎么映射到 0~200?
  2. 为什么要 with SingleTickerProviderStateMixin?什么时候用 TickerProviderStateMixin?
  3. 写一个 Hero 动画:从列表的圆形头像过渡到详情页的大头像,需要哪两个条件?

小结一句话

隐式动画处理简单属性变化,显式动画用 Controller + Tween + AnimatedBuilder 处理一切复杂场景;Hero 解决跨页面共享元素过渡。

延伸

  • 官方动画教程:<https://docs.flutter.dev/ui/animations>
  • Curves 可视化:<https://api.flutter.dev/flutter/animation/Curves-class.html>
  • 下一课:网络与本地存储,把动画背后真实的数据接通。
加载本节练习…
← 上一课状态管理下一课 →网络与本地存储