Flutter: 动画系统
动画是 UI 的灵魂——没有动画的界面像幻灯片,有动画的界面像电影。
📋 前置知识:需要先掌握以下内容
1. 你将学到
- 隐式动画:AnimatedContainer、AnimatedOpacity、AnimatedSwitcher、AnimatedCrossFade
- 显式动画:AnimationController + Tween + CurvedAnimation
- Hero 过渡动画与 PageRouteBuilder 自定义页面转场
- AnimatedBuilder / AnimatedWidget 精准控制重建范围
- ShopApp:商品卡片飞入购物车动画 + 页面共享元素过渡
2. 一个界面生硬的真实故事
(1) 痛点:状态切换像换幻灯片
Bob 的 ShopApp 商品详情页,点击"加入购物车"后数量直接从 0 变成 1,价格从 $0 跳到 $99.99,购物车角标从空突然变成"3"。用户觉得应用"卡了"或者"出 bug 了"——实际上只是缺少过渡动画。35% 的用户在动画缺失的版本报告了"界面卡顿"。
(2) 隐式动画的解法
Flutter 的隐式动画组件只需指定目标值,框架自动生成过渡动画。
DART
import 'package:flutter/material.dart';
// Before: instant change (looks like a bug)
Text('\$$_total')
// After: smooth animation
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text('\$$_total', key: ValueKey(_total)),
)
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(3) 收益:用户感知流畅度提升
Bob 添加动画后,"界面卡顿"报告从 35% 降到 2%,用户停留时长增加 20%。
3. 动画体系全景
graph TD
A[Animation System] --> IA[Implicit Animation]
A --> EA[Explicit Animation]
IA --> AC[AnimatedContainer]
IA --> AO[AnimatedOpacity]
IA --> AS2[AnimatedSwitcher]
EA --> ACTRL[AnimationController]
ACTRL --> TW[Tween]
ACTRL --> CURVE[CurvedAnimation]
EA --> AB[AnimatedBuilder]
EA --> HERO[Hero Transition]
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(1) 隐式 vs 显式动画
| 维度 | 隐式动画 | 显式动画 |
|---|---|---|
| 控制方式 | 自动(设目标值即可) | 手动(Controller 驱动) |
| 复杂度 | 低 | 高 |
| 灵活度 | 有限 | 完全控制 |
| 循环/反向 | 不支持 | 支持 |
| 适用场景 | 简单过渡 | 复杂自定义动画 |
4. 隐式动画
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:AnimatedContainer 展开收起
DART
import 'package:flutter/material.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 GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutCubic,
height: _expanded ? 200 : 80,
padding: EdgeInsets.all(_expanded ? 16 : 8),
decoration: BoxDecoration(
color: _expanded ? Colors.blue[50] : Colors.white,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: [BoxShadow(blurRadius: _expanded ? 12 : 4, color: Colors.black12)],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Pro Laptop', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
if (_expanded) ...[
const SizedBox(height: 12),
const Text('Latest model with M3 chip. 16GB RAM, 512GB SSD.'),
const SizedBox(height: 8),
const Text('\$1,299.99', style: TextStyle(fontSize: 20, color: Colors.green)),
],
]),
),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:AnimatedSwitcher 数字切换
DART
import 'package:flutter/material.dart';
class AnimatedPrice extends StatelessWidget {
final double price;
const AnimatedPrice({super.key, required this.price});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Text('\$${price.toStringAsFixed(2)}',
key: ValueKey(price),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green),
),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:AnimatedCrossFade 视图切换
DART
import 'package:flutter/material.dart';
// 假设 isFavorite 已在别处定义
// bool isFavorite = false;
AnimatedCrossFade(
firstChild: const Icon(Icons.favorite_border, size: 32),
secondChild: const Icon(Icons.favorite, size: 32, color: Colors.red),
crossFadeState: isFavorite ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
)
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
5. 显式动画
(1) AnimationController + Tween + Curve
| 组件 | 职责 |
|---|---|
AnimationController |
控制动画时间、方向、状态 |
Tween<T> |
定义起始值和结束值 |
CurvedAnimation |
应用缓动曲线 |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:商品飞入购物车动画
DART
import 'package:flutter/material.dart';
class FlyToCartAnimation extends StatefulWidget {
final GlobalKey cartKey;
final Widget child;
final VoidCallback onComplete;
const FlyToCartAnimation({super.key, required this.cartKey, required this.child, required this.onComplete});
@override
State<FlyToCartAnimation> createState() => _FlyToCartAnimationState();
}
class _FlyToCartAnimationState extends State<FlyToCartAnimation> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) widget.onComplete();
});
}
void startFly() {
_controller.forward(from: 0);
}
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(animation: _animation, builder: (context, child) {
final cartBox = widget.cartKey.currentContext?.findRenderObject() as RenderBox?;
if (cartBox == null) return child!;
final cartPos = cartBox.localToGlobal(Offset.zero);
return Transform.translate(
offset: Offset(cartPos.dx * _animation.value, cartPos.dy * _animation.value),
child: Transform.scale(scale: 1 - _animation.value * 0.5, child: child),
);
}, child: widget.child);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
6. Hero 过渡动画
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:商品图片共享元素过渡
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - product: 见第11课 json_serializable 模型
// In product list
Hero(
tag: 'product_${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(product.imageUrl, fit: BoxFit.cover),
),
)
// In product detail page
Hero(
tag: 'product_${product.id}',
child: Image.network(product.imageUrl, fit: BoxFit.cover),
)
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:PageRouteBuilder 自定义转场
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - DetailPage/product: 见第11课/第14课
class SlideUpRoute extends PageRouteBuilder {
final Widget page;
SlideUpRoute(this.page) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 400),
);
}
// Usage
Navigator.push(context, SlideUpRoute(DetailPage(product: product)));
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
7. Curves 缓动曲线
| Curve | 效果 | 适用场景 |
|---|---|---|
Curves.linear |
匀速 | 很少用 |
Curves.easeIn |
慢→快 | 退出 |
Curves.easeOut |
快→慢 | 进入 |
Curves.easeInOut |
慢→快→慢 | 通用 |
Curves.easeOutCubic |
快速到达 | 弹性效果 |
Curves.elasticOut |
弹性回弹 | 趣味动画 |
Curves.bounceOut |
弹跳 | 游戏感 |
8. 完整示例:ShopApp 商品卡片动画套件
DART
import 'package:flutter/material.dart';
// 自定义类定义来源:
// - Product: 见第11课 json_serializable 模型
class AnimatedProductCard extends StatefulWidget {
final Product product;
final VoidCallback onAddToCart;
const AnimatedProductCard({super.key, required this.product, required this.onAddToCart});
@override
State<AnimatedProductCard> createState() => _AnimatedProductCardState();
}
class _AnimatedProductCardState extends State<AnimatedProductCard> with SingleTickerProviderStateMixin {
bool _isFavorite = false;
bool _isAdded = false;
late AnimationController _scaleController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_scaleController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut));
}
void _onTap() {
_scaleController.forward().then((_) => _scaleController.reverse());
}
void _addToCart() {
setState(() => _isAdded = true);
widget.onAddToCart();
Future.delayed(const Duration(seconds: 2), () => setState(() => _isAdded = false));
}
@override
void dispose() { _scaleController.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _scaleController.forward(),
onTapUp: (_) => _scaleController.reverse(),
onTapCancel: () => _scaleController.reverse(),
child: ScaleTransition(
scale: _scaleAnimation,
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Expanded(flex: 3, child: Stack(children: [
Hero(tag: 'product_${widget.product.id}',
child: Image.network(widget.product.imageUrl, fit: BoxFit.cover, width: double.infinity)),
Positioned(top: 8, right: 8,
child: AnimatedSwitcher(duration: const Duration(milliseconds: 300),
child: IconButton(key: ValueKey(_isFavorite),
icon: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : Colors.white),
onPressed: () => setState(() => _isFavorite = !_isFavorite),
style: IconButton.styleFactory(backgroundColor: Colors.white70)))),
]),
Expanded(flex: 2, child: Padding(padding: const EdgeInsets.all(8), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
const Spacer(),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('\$${widget.product.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.green)),
AnimatedSwitcher(duration: const Duration(milliseconds: 300),
child: _isAdded ? const Icon(Icons.check_circle, color: Colors.green, key: ValueKey('added'))
: IconButton(icon: const Icon(Icons.add_shopping_cart, size: 20),
onPressed: _addToCart, key: const ValueKey('add'))),
]),
])))),
]),
),
),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
❓ 常见问题
Q 隐式动画和显式动画怎么选?
A 能用隐式就用隐式(简单安全);需要循环、反向、精确控制时用显式。
Q AnimationController 必须在 dispose 中释放吗?
A 是的。不释放会内存泄漏。用 SingleTickerProviderStateMixin 提供 vsync。
Q AnimatedBuilder 和 AnimatedWidget 有什么区别?
A AnimatedBuilder 是通用回调式,AnimatedWidget 需要子类化。AnimatedBuilder 更灵活,推荐使用。
Q Hero 动画两个页面 tag 必须一致吗?
A 是的。Hero 用 tag 匹配两个页面的 Widget,tag 必须全局唯一且一致。
Q 动画卡顿怎么优化?
A 1) 用 AnimatedBuilder 限制重建范围;2) 用 RepaintBoundary 隔离重绘;3) 避免在动画回调中做耗时操作。
Q 多个动画怎么编排?
A 用 Interval 让多个动画错开时间,或用 AnimationController 的 addStatusListener 手动串联。
📖 小节
- 隐式动画:设目标值即可,Flutter 自动过渡(AnimatedContainer/Switcher)
- 显式动画:AnimationController 完全控制,支持循环和反向
- Hero 实现页面间共享元素过渡
- Curves 控制动画节奏,easeOutCubic 最常用
- AnimatedBuilder 限制重建范围,优化动画性能
📝 作业
- 基础题(难度⭐):用 AnimatedContainer 实现一个可展开/收起的卡片,点击切换高度和背景色。
- 进阶题(难度⭐⭐):实现商品列表 → 详情页的 Hero 过渡动画,图片从卡片位置飞到详情页大图位置。
- 挑战题(难度⭐⭐⭐):实现完整的"飞入购物车"动画:点击加购后,商品缩略图从卡片飞向 AppBar 购物车图标,飞行中缩小并淡出。