开篇:动画是用户体验的灵魂
从滑动的列表项到渐显的页面,从点击按钮时的 ripples 到页面转场时的共享元素动画——动画让界面从"能用"变成"好用"。Flutter 的动画系统由层叠的 API 组成:从开箱即用的 Implicit Animations,到精确控制的 Explicit Animations,再到与 UI 框架深度集成的 Hero 和物理动画。
本章将系统介绍 Flutter 的各类动画方案,帮助你为应用添加恰到好处的动效——既能引导用户注意力,又不会因过度炫技而喧宾夺主。
一、Implicit Animations:隐式动画(零代码框架动画)
当 Widget 属性变化时,Flutter 可自动插值过渡。
1.1 内置动画 Widget
class ImplicitAnimationDemo extends StatefulWidget {
@override
_ImplicitAnimationDemoState createState() => _ImplicitAnimationDemoState();
}
class _ImplicitAnimationDemoState extends State<ImplicitAnimationDemo> {
bool _expanded = false;
Color _color = Colors.blue;
double _opacity = 1.0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onTap: () => setState(() {
_expanded = !_expanded;
_color = _expanded ? Colors.orange : Colors.blue;
_opacity = _expanded ? 0.5 : 1.0;
}),
child: Column(
children: [
// 尺寸动画
AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
color: _color,
borderRadius: BorderRadius.circular(_expanded ? 20 : 8),
),
// 透明度动画
AnimatedOpacity(
duration: Duration(milliseconds: 300),
opacity: _opacity,
child: Text('渐隐文本'),
),
// 位置动画
AnimatedAlign(
duration: Duration(milliseconds: 300),
alignment: _expanded ? Alignment.centerRight : Alignment.centerLeft,
child: FlutterLogo(size: 50),
),
// 内边距动画
AnimatedPadding(
duration: Duration(milliseconds: 300),
padding: EdgeInsets.all(_expanded ? 32 : 8),
child: Container(color: Colors.green, child: Text('Padding')),
),
// 缩放动画
AnimatedScale(
duration: Duration(milliseconds: 300),
scale: _expanded ? 1.5 : 1.0,
child: Icon(Icons.star, size: 50),
),
// 旋转动画
AnimatedRotation(
duration: Duration(milliseconds: 500),
turns: _expanded ? 0.5 : 0,
child: Icon(Icons.refresh, size: 50),
),
],
),
),
);
}
}
1.2 AnimatedSwitcher:子 Widget 切换动画
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: _isLoading
? CircularProgressIndicator(key: ValueKey('loading'))
: Text('数据加载完成', key: ValueKey('done')),
)
1.3 TweenAnimationBuilder:自定义插值
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(seconds: 2),
curve: Curves.bounceOut,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, -100 * value),
child: Opacity(
opacity: value,
child: child,
),
);
},
child: Text('弹跳入场!', style: TextStyle(fontSize: 24)),
)
一句话总结:隐式动画让你只需改变属性值就能获得流畅过渡,是 Flutter 最省力的动画方案。
二、Explicit Animations:显式动画(精确控制)
2.1 AnimationController + Tween
class ExplicitAnimationDemo extends StatefulWidget {
@override
_ExplicitAnimationDemoState createState() => _ExplicitAnimationDemoState();
}
class _ExplicitAnimationDemoState extends State<ExplicitAnimationDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // TickerProvider 防止离屏动画消耗资源
duration: Duration(seconds: 2),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
// 监听动画状态
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
print('动画完成');
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
if (_controller.status == AnimationStatus.completed) {
_controller.reverse();
} else {
_controller.forward();
}
},
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value * 2 * pi,
child: Transform.scale(
scale: 0.5 + _animation.value * 0.5,
child: child,
),
);
},
child: Container(
width: 100,
height: 100,
color: Colors.purple,
child: Center(child: Text('Tap')),
),
),
);
}
}
2.2 交错动画(Staggered Animation)
class StaggeredDemo extends StatefulWidget {
@override
_StaggeredDemoState createState() => _StaggeredDemoState();
}
class _StaggeredDemoState extends State<StaggeredDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1500),
);
_animations = List.generate(5, (index) {
final start = index * 0.15;
final end = start + 0.6;
return Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOut),
),
);
});
_controller.forward();
}
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(5, (index) {
return AnimatedBuilder(
animation: _animations[index],
builder: (context, child) {
return Opacity(
opacity: _animations[index].value,
child: Transform.translate(
offset: Offset(50 * (1 - _animations[index].value), 0),
child: child,
),
);
},
child: ListTile(title: Text('Item $index')),
);
}),
);
}
}
一句话总结:显式动画通过 AnimationController 赋予你完整的控制能力——播放、暂停、倒放、逐帧监听——适合需要精确时序的复杂动画。
三、Hero 动画与页面转场
// 列表页
class ProductList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: GridView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return GestureDetector(
onTap: () => context.push('/detail/${product.id}'),
child: Hero(
tag: 'product-${product.id}',
flightShuttleBuilder: (flightContext, animation,
flightDirection, fromHeroContext, toHeroContext) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return ClipRRect(
borderRadius: BorderRadius.lerp(
BorderRadius.circular(12), // 从列表的圆角
BorderRadius.zero, // 到详情的直角
animation.value,
)!,
child: Image.network(product.imageUrl),
);
},
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(product.imageUrl),
),
),
);
},
),
);
}
}
// 详情页
class ProductDetail extends StatelessWidget {
final String productId;
const ProductDetail({super.key, required this.productId});
@override
Widget build(BuildContext context) {
final product = findProduct(productId);
return Scaffold(
body: Column(
children: [
Hero(
tag: 'product-$productId',
child: Image.network(product.imageUrl, height: 300, fit: BoxFit.cover),
),
Text(product.name),
],
),
);
}
}
一句话总结:Hero 动画通过匹配两个页面间的 tag,自动计算插值路径实现共享元素转场,是 Flutter 中最具辨识度的动画效果。
四、Lottie 与 Rive 集成
// Lottie(After Effects 导出)
import 'package:lottie/lottie.dart';
Lottie.asset(
'assets/animations/loading.json',
width: 200,
height: 200,
repeat: true,
controller: _lottieController, // 可选:控制播放
onLoaded: (composition) {
_lottieController.duration = composition.duration;
},
)
// Rive(交互式矢量动画)
import 'package:rive/rive.dart';
class RiveButton extends StatefulWidget {
@override
_RiveButtonState createState() => _RiveButtonState();
}
class _RiveButtonState extends State<RiveButton> {
StateMachineController? _controller;
SMIInput<bool>? _hoverInput;
void _onRiveInit(Artboard artboard) {
_controller = StateMachineController.fromArtboard(artboard, 'State Machine');
artboard.addController(_controller!);
_hoverInput = _controller!.findInput<bool>('hover') as SMIInput<bool>;
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _hoverInput?.value = true,
onExit: (_) => _hoverInput?.value = false,
child: RiveAnimation.asset(
'assets/animations/button.riv',
onInit: _onRiveInit,
),
);
}
}
一句话总结:Lottie 适合展示型动画(加载、成功提示),Rive 适合交互式动画(响应输入的状态机动画)。
五、手势系统
class GestureDemo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
// 点击
onTap: () => print('Tap'),
onDoubleTap: () => print('Double tap'),
onLongPress: () => print('Long press'),
// 滑动
onHorizontalDragStart: (details) => print('Drag start'),
onHorizontalDragUpdate: (details) => print('Delta: ${details.delta}'),
onHorizontalDragEnd: (details) => print('Velocity: ${details.primaryVelocity}'),
// 捏合缩放
onScaleStart: (details) => print('Scale start: ${details.focalPoint}'),
onScaleUpdate: (details) => print('Scale: ${details.scale}'),
child: Container(width: 300, height: 300, color: Colors.blue),
);
}
}
// Dismissible:滑动删除
Dismissible(
key: Key(item.id),
direction: DismissDirection.endToStart,
background: Container(color: Colors.red, child: Icon(Icons.delete)),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('确认删除?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消')),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text('删除')),
],
),
);
},
onDismissed: (_) => removeItem(item.id),
child: ListTile(title: Text(item.title)),
)
FAQ
Q1: Implicit 和 Explicit 动画怎么选?
- 只需简单属性过渡 → Implicit(AnimatedContainer 等)
- 需要精确时序、循环、手势联动 → Explicit(AnimationController)
- 不确定时,先用 Implicit,不够用再升级
Q2: Hero 动画 tag 冲突怎么办?
确保 tag 全局唯一,通常使用 ID:hero-product-${product.id}。列表中允许多个 Hero 使用相同 tag(如网格中的同一产品),Flutter 会自动处理。
Q3: 动画卡顿怎么排查?
打开 DevTools → Performance → Enable “Track widget builds”,检查 build 是否在每帧中被调用太多次。如果动画面板显示红色条,说明动画计算占用了过多的 UI 线程时间。
相关阅读
- https://plumephp.com/flutter-widgets-layout/ — Widget 体系
- https://plumephp.com/flutter-performance-optimization/ — 性能优化
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。