Flutter交错动画与物理动画—Staggered Animation与SpringSimulation|新宇宙博客返回列表交错动画与物理动画
详解Flutter Staggered Animation时间线编排、物理模拟动画、弹簧/摩擦/重力模拟及手势驱动交互动画

交错动画与物理动画 (Staggered & Physics Animations)
模块:六 - 动画系统
预计阅读时间:35 分钟
前言
如果说隐式/显式动画是 Flutter 动画的基础,那么交错动画与物理动画就是让 UI 从"能动"进化到"真实感"的关键技术。
交错动画(Staggered Animation):多个元素按时间序列依次出现,营造有节奏的视觉层次感——就像好莱坞大片的片头字幕,每个单词依次弹出,而不是同时出现。
物理动画(Physics Animation):动画行为遵循物理规律(弹簧、摩擦力、重力),而不是机械的缓动曲线——就像 iOS 的弹性滚动,手势结束后还会有自然的回弹感。
本文深入这两个领域的底层实现,帮你写出有"手感"的动画。
一、交错动画的核心机制:Interval
1.1 理解 Interval Curve
交错动画的核心是 Interval——一种特殊的 Curve,它让动画只在 整体时间线的某个区间 内激活:
AnimationController 的时间线:[0.0 ─────────────────── 1.0]
Element 1 的 Interval: [0.0 ── 0.4]
Element 2 的 Interval: [0.2 ── 0.6]
Element 3 的 Interval: [0.4 ── 1.0]
视觉效果:Element 1 先动,Element 2 稍后,Element 3 最后,相互重叠
// 一个 controller 驱动所有元素
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
// 三个元素各自的 Animation,通过 Interval 错开时间
final item1Animation = Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut), // 0~480ms
));
final item2Animation = Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: const Interval(0.2, 0.6, curve: Curves.easeOut), // 240~720ms
));
final item3Animation = Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: const Interval(0.4, 1.0, curve: Curves.easeOut), // 480~1200ms
));
关键理解:Interval(0.2, 0.6) 的含义是:
- 当 controller.value < 0.2 时,该 Animation 值 = 0(还没开始)
- 当 controller.value 在 [0.2, 0.6] 时,该 Animation 线性/曲线插值
- 当 controller.value > 0.6 时,该 Animation 值 = 1(已完成)
1.2 列表项依次飞入的完整实现
class StaggeredListDemo extends StatefulWidget {
const StaggeredListDemo({super.key});
@override
State<StaggeredListDemo> createState() => _StaggeredListDemoState();
}
class _StaggeredListDemoState extends State<StaggeredListDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
final List<String> _items = [
'设计精美的界面',
'流畅的动画效果',
'卓越的用户体验',
'高性能渲染引擎',
'跨平台一致表现',
];
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// 页面加载后延迟 300ms 触发
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) _controller.forward();
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// 为每个索引生成错开的动画
Animation<double> _buildSlideAnimation(int index) {
// 每项延迟 0.1,持续 0.4
final start = index * 0.15;
final end = start + 0.4;
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(0.0, 1.0), curve: Curves.easeOut),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
itemBuilder: (context, index) {
final animation = _buildSlideAnimation(index);
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: animation.value,
child: Transform.translate(
offset: Offset(0, 30 * (1 - animation.value)), // 从下往上滑入
child: child,
),
);
},
child: Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(_items[index]),
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_controller.reset();
_controller.forward();
},
child: const Icon(Icons.replay),
),
);
}
}
1.3 复杂交错:多属性同时但不同步
class _CardRevealState extends State<CardReveal>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
// 背景先展开
late Animation<double> _backgroundScale;
// 图片稍后淡入
late Animation<double> _imageOpacity;
// 文字最后从底部滑入
late Animation<Offset> _textSlide;
// 按钮最后弹出
late Animation<double> _buttonScale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_backgroundScale = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
),
);
_imageOpacity = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 0.7, curve: Curves.easeIn),
),
);
_textSlide = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 0.9, curve: Curves.easeOut),
));
_buttonScale = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
// 弹性曲线:按钮弹出来
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Card(
child: Column(
children: [
// 背景区域
ScaleTransition(
scale: _backgroundScale,
child: Container(
height: 120,
color: Colors.blue.shade100,
),
),
// 图片
FadeTransition(
opacity: _imageOpacity,
child: const Icon(Icons.image, size: 64),
),
// 文字
SlideTransition(
position: _textSlide,
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('卡片内容', style: TextStyle(fontSize: 18)),
),
),
// 按钮
ScaleTransition(
scale: _buttonScale,
child: ElevatedButton(
onPressed: () {},
child: const Text('立即体验'),
),
),
const SizedBox(height: 16),
],
),
);
},
);
}
}
二、物理动画:让运动有真实感
2.1 物理动画 vs 缓动曲线的本质区别
| 维度 | 缓动曲线(Curve) | 物理模拟(Simulation) |
|---|
| 时长 | 固定 duration | 由物理参数决定,不固定 |
| 终止条件 | 固定时间结束 | 达到平衡状态时结束 |
| 输入 | 时间进度 [0,1] | 当前位置 + 速度 |
| 自然感 | 较机械 | 更自然 |
| 中途打断 | 需要手动处理 | 自然接续 |
物理动画的核心:Simulation 接口,它定义了一个物理过程:
abstract class Simulation {
// 在时间 t 时,返回位置
double x(double time);
// 在时间 t 时,返回速度
double dx(double time);
// 是否已经达到"静止"状态
bool isDone(double time);
}
2.2 SpringSimulation:弹簧动画
F = -kx - cv
其中:
k = stiffness(刚度/弹力系数)—— 越大弹簧越硬,回弹越快
c = damping(阻尼系数)—— 越大振荡越少,越小振荡越多
m = mass(质量)—— 默认 1.0
class SpringCard extends StatefulWidget {
const SpringCard({super.key});
@override
State<SpringCard> createState() => _SpringCardState();
}
class _SpringCardState extends State<SpringCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double _dragOffset = 0.0;
@override
void initState() {
super.initState();
_controller = AnimationController.unbounded(vsync: this);
// unbounded:允许超出 [0,1] 范围,物理模拟需要这个
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onDragEnd(DragEndDetails details) {
// 获取拖拽结束时的速度(像素/秒)
final velocity = details.velocity.pixelsPerSecond.dy;
// 创建弹簧描述
const spring = SpringDescription(
mass: 1.0,
stiffness: 500.0, // 高刚度 = 快速回弹
damping: 20.0, // 适当阻尼 = 轻微振荡后稳定
);
// 创建弹簧模拟:从当前位置 _dragOffset 以 velocity 速度,弹回到 0.0
final simulation = SpringSimulation(
spring,
_dragOffset, // 起始位置(当前拖拽偏移)
0.0, // 目标位置(回弹到 0)
velocity / 1000, // 转换为 dp/ms 单位
);
// 用物理模拟驱动 controller
_controller.animateWith(simulation);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragUpdate: (details) {
_controller.value += details.delta.dy;
setState(() => _dragOffset = _controller.value);
},
onVerticalDragEnd: _onDragEnd,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _controller.value),
child: child,
);
},
child: Container(
width: 200,
height: 120,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text('拖动我', style: TextStyle(color: Colors.white)),
),
),
),
);
}
}
2.3 FrictionSimulation:摩擦力减速
// 用于列表惯性滚动结束的模拟
void _onFling(double velocity) {
final simulation = FrictionSimulation(
0.135, // friction 摩擦系数(0~1,越大减速越快)
_currentPosition, // 起始位置
velocity, // 初始速度
);
_controller.animateWith(simulation);
}
2.4 GravitySimulation:重力模拟
// 模拟物体从空中自由落体
final gravitySimulation = GravitySimulation(
980.0, // acceleration(重力加速度,像素/ms²)
0.0, // startPosition(起始高度)
500.0, // endPosition(地面位置,到达后停止)
0.0, // startVelocity(初始速度,0 = 自由落体)
);
2.5 AnimationController.fling:快速启动物理动画
// 不需要手动创建 SpringSimulation
// fling 内部使用 SpringSimulation
_controller.fling(
velocity: 2.0, // 正值向前,负值向后
// 内部使用 kDefaultSpring,可以通过 springDescription 参数覆盖
springDescription: const SpringDescription(
mass: 1.0,
stiffness: 500.0,
damping: 25.0,
),
);
三、自定义 Curve:贝塞尔曲线
3.1 CubicBezierCurve 的数学基础
Flutter 的 Cubic 类实现了三次贝塞尔缓动,参数对应控制点坐标:
// 标准 CSS cubic-bezier(0.25, 0.1, 0.25, 1.0) 对应
const myCurve = Cubic(0.25, 0.1, 0.25, 1.0);
// 常见缓动的贝塞尔系数(来自 Material Design)
const easeInOut = Cubic(0.4, 0.0, 0.2, 1.0); // Material 标准
const sharp = Cubic(0.4, 0.0, 0.6, 1.0); // 快进快出
const decelerate = Cubic(0.0, 0.0, 0.2, 1.0); // 快进慢出
const accelerate = Cubic(0.4, 0.0, 1.0, 1.0); // 慢进快出
3.2 完全自定义 Curve
/// 三段式弹跳曲线:快速前进 → 超出目标 → 弹回
class BouncyCurve extends Curve {
const BouncyCurve();
@override
double transformInternal(double t) {
if (t < 0.7) {
// 前 70% 时间:快速推进到 1.1(超过目标)
return Curves.easeOut.transform(t / 0.7) * 1.1;
} else {
// 后 30% 时间:从 1.1 弹回到 1.0
final overshoot = 1.1 - (t - 0.7) / 0.3 * 0.1;
return overshoot;
}
}
}
// 使用自定义曲线
final animation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const BouncyCurve(),
),
);
四、Rive & Lottie:矢量动画集成
4.1 什么时候使用 Rive/Lottie
| 场景 | 推荐方案 |
|---|
| 简单状态过渡(出现/消失/颜色变化) | Flutter 原生动画 |
| 复杂骨骼动画(角色、插图) | Rive |
| 设计师输出的 AE 动画 | Lottie |
| 需要交互式状态机(点击切换动画状态) | Rive(状态机功能更强) |
| 纯播放不需要交互 | Lottie 或 Rive 均可 |
4.2 Rive 集成与状态机
dependencies:
rive: ^0.12.0
import 'package:rive/rive.dart';
class RiveButtonDemo extends StatefulWidget {
const RiveButtonDemo({super.key});
@override
State<RiveButtonDemo> createState() => _RiveButtonDemoState();
}
class _RiveButtonDemoState extends State<RiveButtonDemo> {
// Rive 状态机输入
SMIBool? _pressInput;
void _onRiveInit(Artboard artboard) {
final controller = StateMachineController.fromArtboard(
artboard,
'Button State Machine', // 状态机名称(在 Rive 编辑器中定义)
);
if (controller != null) {
artboard.addController(controller);
// 获取布尔输入控制器
_pressInput = controller.findInput<bool>('isPressed') as SMIBool?;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _pressInput?.change(true),
onTapUp: (_) => _pressInput?.change(false),
child: SizedBox(
width: 200,
height: 60,
child: RiveAnimation.asset(
'assets/button.riv',
onInit: _onRiveInit,
),
),
);
}
}
4.3 Lottie 集成
dependencies:
lottie: ^3.0.0
import 'package:lottie/lottie.dart';
// 最简单的使用方式
Lottie.asset(
'assets/loading_animation.json',
width: 200,
height: 200,
repeat: true,
)
// 精细控制:与 AnimationController 联动
class LottieControlDemo extends StatefulWidget {
const LottieControlDemo({super.key});
@override
State<LottieControlDemo> createState() => _LottieControlDemoState();
}
class _LottieControlDemoState extends State<LottieControlDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Lottie.asset(
'assets/checkmark.json',
controller: _controller,
onLoaded: (composition) {
// 设置 duration 与 Lottie 动画时长一致
_controller.duration = composition.duration;
_controller.forward(); // 播放一次
},
);
}
}
五、复杂实战:可拖拽弹性卡片
class DraggableSpringCard extends StatefulWidget {
const DraggableSpringCard({super.key});
@override
State<DraggableSpringCard> createState() => _DraggableSpringCardState();
}
class _DraggableSpringCardState extends State<DraggableSpringCard>
with TickerProviderStateMixin {
// 弹簧回弹 controller(unbounded 允许超出范围)
late AnimationController _springController;
// 入场交错动画 controller
late AnimationController _staggerController;
late Animation<double> _titleAnimation;
late Animation<double> _subtitleAnimation;
late Animation<double> _buttonAnimation;
Offset _cardOffset = Offset.zero;
bool _isDragging = false;
static const _springDesc = SpringDescription(
mass: 1.0,
stiffness: 400.0,
damping: 18.0, // 欠阻尼 = 会振荡一下再停止
);
@override
void initState() {
super.initState();
_springController = AnimationController.unbounded(vsync: this)
..addListener(() {
setState(() {
_cardOffset = Offset(
_springController.value,
_cardOffset.dy,
);
});
});
_staggerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_titleAnimation = _buildStaggeredOpacity(0.0, 0.4);
_subtitleAnimation = _buildStaggeredOpacity(0.25, 0.65);
_buttonAnimation = _buildStaggeredOpacity(0.55, 1.0);
_staggerController.forward();
}
Animation<double> _buildStaggeredOpacity(double start, double end) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _staggerController,
curve: Interval(start, end, curve: Curves.easeOut),
),
);
}
@override
void dispose() {
_springController.dispose();
_staggerController.dispose();
super.dispose();
}
void _onDragUpdate(DragUpdateDetails details) {
setState(() {
_isDragging = true;
_cardOffset += details.delta;
_springController.value = _cardOffset.dx;
});
}
void _onDragEnd(DragEndDetails details) {
_isDragging = false;
final velocity = details.velocity.pixelsPerSecond.dx;
// 如果拖动超过阈值,飞出屏幕;否则弹回原位
if (_cardOffset.dx.abs() > 100) {
final targetX = _cardOffset.dx > 0 ? 400.0 : -400.0;
_springController.animateTo(targetX,
duration: const Duration(milliseconds: 300));
} else {
// 弹回原位
final simulation = SpringSimulation(
_springDesc,
_cardOffset.dx,
0.0,
velocity / 1000,
);
_springController.animateWith(simulation);
setState(() => _cardOffset = Offset(0, _cardOffset.dy));
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: Transform.translate(
offset: Offset(_springController.value, _cardOffset.dy),
child: Transform.rotate(
// 卡片随拖动角度倾斜
angle: _springController.value / 1000,
child: Card(
elevation: _isDragging ? 12 : 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 交错入场:标题
FadeTransition(
opacity: _titleAnimation,
child: const Text(
'Flutter 物理动画',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 8),
// 交错入场:副标题
FadeTransition(
opacity: _subtitleAnimation,
child: const Text(
'拖动体验弹簧回弹效果',
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 16),
// 交错入场:按钮
ScaleTransition(
scale: _buttonAnimation,
child: ElevatedButton(
onPressed: () {},
child: const Text('开始体验'),
),
),
],
),
),
),
),
),
);
}
}
六、SpringDescription 参数调试指南
欠阻尼(damping² < 4 * stiffness * mass):
振荡 → 来回摆动后稳定,像弹簧
damping = 10, stiffness = 500 → 多次振荡
临界阻尼(damping² = 4 * stiffness * mass):
最快到达目标且不振荡
过阻尼(damping² > 4 * stiffness * mass):
缓慢趋近目标,无振荡,像黏稠液体
// 实用的弹簧参数参考:
// 1. iOS 弹性滚动感(轻微振荡)
const iosSpring = SpringDescription(
mass: 1.0,
stiffness: 200.0,
damping: 20.0,
);
// 2. 快速弹回(几乎无振荡)
const quickSpring = SpringDescription(
mass: 1.0,
stiffness: 500.0,
damping: 40.0,
);
// 3. 果冻效果(明显振荡)
const jellySpring = SpringDescription(
mass: 1.0,
stiffness: 300.0,
damping: 8.0, // 低阻尼 = 多次振荡
);
七、Simulation.isDone 的判定逻辑
当我们自定义 Simulation 时,需要正确实现 isDone:
class CustomBounceSimulation extends Simulation {
CustomBounceSimulation({
required double startPosition,
required double startVelocity,
}) : _position = startPosition,
_velocity = startVelocity;
double _position;
double _velocity;
static const double _gravity = 980.0; // 像素/s²
static const double _bounceFactor = 0.6; // 弹性系数
@override
double x(double time) {
// 简化:只计算单次弹跳
return _position + _velocity * time - 0.5 * _gravity * time * time;
}
@override
double dx(double time) {
return _velocity - _gravity * time;
}
@override
bool isDone(double time) {
// 速度接近 0 且位置接近地面
final currentVelocity = dx(time).abs();
final currentPosition = x(time);
return currentVelocity < tolerance.velocity &&
currentPosition.abs() < tolerance.distance;
}
}
tolerance 是 Simulation 的内置属性,默认 Tolerance.defaultTolerance(速度 < 0.1 dp/s,距离 < 0.1 dp),防止动画永远"无限趋近"而不结束。
八、过关自测
问题一:Interval(0.3, 0.7) 与 Interval(0.3, 0.7, curve: Curves.easeOut) 有什么区别?
前者在 [0.3, 0.7] 区间内线性插值;后者在该区间内按 easeOut 曲线插值。Interval 本身是一个 Curve,curve 参数是它内部的子曲线,控制该区间内的插值方式。
问题二:为什么物理动画需要 AnimationController.unbounded?
普通 AnimationController 的值被夹在 [lowerBound, upperBound](默认 [0.0, 1.0])之间。而弹簧动画可能需要"超过目标值"再弹回(过冲),如果值被强制截断到 [0,1],弹簧模拟就失效了。unbounded 构造函数不设置上下限,允许值任意变化。
问题三:SpringSimulation 的 isDone 什么时候返回 true?
当满足以下两个条件时:速度的绝对值 < tolerance.velocity(默认 0.1),且与目标位置的距离 < tolerance.distance(默认 0.1)。这防止了弹簧无限趋近目标值但永远不结束的问题。
总结
| 技术 | 核心机制 | 适用场景 |
|---|
| 交错动画 | Interval 切分时间轴 | 列表入场、多元素依次出现 |
| 弹簧动画 | SpringSimulation | 拖拽回弹、弹性出现 |
| 摩擦动画 | FrictionSimulation | 惯性滑动减速 |
| 重力动画 | GravitySimulation | 下落效果 |
| 自定义曲线 | 继承 Curve | 独特的缓动感 |
| Rive 状态机 | 状态机驱动骨骼动画 | 复杂交互动画 |
| Lottie | JSON 播放 AE 动画 | 设计稿直出动画 |
物理动画的核心价值在于:不需要预先设定 duration,动画自然终止于"静止状态"。这与手势交互天然契合——用户手势结束后的速度可以直接传入物理模拟,产生连贯自然的感觉。