
前言
一个没有动画的列表是"死"的。打开应用,所有列表项齐刷刷同时出现------这种感觉就像走进一间空房间,所有家具瞬间从地面"弹"出来,毫无层次感。
优秀的列表入场动画能给应用注入呼吸感。Google 的 Material Design 指南也特别强调"错峰动画"(Staggered Animation):子元素不应同时出现,而应按顺序依次亮相,每个元素比前一个稍晚一点,形成波浪般的视觉效果。
本文将介绍如何封装一个 AnimatedListItem 组件,为鸿蒙 Flutter 备忘录应用的三大列表(备忘录、待办、日记)注入灵动的入场动画。
项目仓库:todo_flutter_harmony
效果分析
拆解"延时错峰入场"这个需求,实际上包含了三个维度的动画:
- 透明度:从 0 到 1 淡入(Opacity 0.0 → 1.0)
- 位置:从下方偏移到原位(Y 偏移 30px → 0)
- 时序:每项比前一项延迟固定时间启动(错峰)
把这三个维度组合起来,就是我们要实现的效果。
AnimationController 基础
Flutter 中实现自定义动画的核心是 AnimationController。这里有几个关键概念需要先厘清:
dart
// 创建一个 AnimationController
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400), // 动画总时长
vsync: this, // 提供 Ticker,与屏幕刷新率同步
);
// 启动动画
_controller.forward();
}
AnimationController 的值在 0.0 到 1.0 之间线性变化(默认 lowerBound: 0.0,upperBound: 1.0)。想要非线性变化?用 CurvedAnimation 包裹:
dart
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOut, // 先快后慢
);
Curves.easeOut 的物理意义是"减速运动"------元素从下方快速弹出,到达原位置时逐渐减速,模拟自然界的惯性感。这比线性运动更符合人类的视觉认知。
错峰延时的实现
错峰的核心在于"每个元素比前一个晚一点启动动画"。最容易想到的方案是 Future.delayed:
dart
// 不推荐
Future.delayed(Duration(milliseconds: index * 50), () {
_controller.forward();
});
但这个方案有两个问题:一是引入了额外的异步开销,二是延迟期��元素处于不可见状态(透明度为 0),用户会看到短暂的"空白"。
更好的做法是用延迟启动 AnimationController:
dart
_controller.forward(from: 0);
结合组件参数,我们让每个 AnimatedListItem 接收一个 delay 参数:
dart
class AnimatedListItem extends StatefulWidget {
final Widget child;
final int delay; // 延时量,单位为毫秒
final Curve curve; // 可选,动画曲线
const AnimatedListItem({
super.key,
required this.child,
this.delay = 0,
this.curve = Curves.easeOut,
});
}
在 initState 中,通过 Future.delayed 延迟启动动画------但和上面的反例不同,这次我们只在动画启动前隐藏元素,启动后立即显示:
dart
class _AnimatedListItemState extends State<AnimatedListItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<Offset> _slideAnimation;
bool _started = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: widget.curve),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(
CurvedAnimation(parent: _controller, curve: widget.curve),
);
// 最关键的一行:延时启动
Future.delayed(Duration(milliseconds: widget.delay), () {
if (mounted) {
setState(() => _started = true);
_controller.forward();
}
});
}
注意 if (mounted) 检查------这在延迟回调中至关重要。如果用户在动画延迟期间导航离开,widget 已经被 dispose,此时调用 setState 或 _controller.forward() 会抛出异常。
构建视图
dart
@override
Widget build(BuildContext context) {
if (!_started) {
// 动画启动前:完全透明,避免闪烁
return Opacity(
opacity: 0.0,
child: widget.child,
);
}
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _opacityAnimation.value,
child: Transform.translate(
offset: Offset(
0,
_slideAnimation.value.dy * 30, // 将 0-0.3 映射到 0-30px
),
child: child,
),
);
},
child: widget.child,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
这里做了两层优化:
- 动画启动前用
Opacity(opacity: 0.0):保持布局占位,避免列表跳动 AnimatedBuilder的child参数 :把widget.child作为预构建 child 传入,避免每次动画帧都重建子树
在列表中使用
在 ListView.builder 中,用法极其简洁:
dart
ListView.builder(
itemCount: memos.length,
itemBuilder: (context, index) {
return AnimatedListItem(
delay: index * 60, // 每项比前一项晚 60ms
child: SlideActionTile(
leftActions: _buildLeftActions(memos[index]),
rightActions: _buildRightActions(memos[index]),
child: MemoCard(memo: memos[index]),
),
);
},
)
index * 60 意味着:
- 第 0 项:延迟 0ms,立即出现
- 第 1 项:延迟 60ms
- 第 2 项:延迟 120ms
- 第 N 项:延迟 N × 60ms
对于 10 个备忘录的列表,最后一个元素的延迟是 540ms,加上 400ms 的动画时长,整个入场序列约 1 秒完成------节奏刚好,不会让用户觉得拖沓。
与 Provider 数据加载的配合
备忘录应用使用 Provider 管理状态,数据在页面 initState 中异步加载:
dart
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MemoProvider>().loadMemos();
});
}
这里的 addPostFrameCallback 确保了在首帧渲染完成后再触发数据加载。当 Provider 的 notifyListeners() 触发 UI 重建时,ListView.builder 会创建新的 AnimatedListItem 实例,每个实例按自己的延时启动动画。
关键点 :数据刷新(如下拉刷新)会触发列表重建,此时所有 AnimatedListItem 重新创建并重新播放入场动画------这是预期行为,重新加载的数据理应重新入场。
如果想让动画只在首次加载时播放,可以在 Provider 中加一个 _hasInitialLoaded 标记,后续加载时传递 delay: 0。
性能考量
这个方案的性能开销很低:
- 每个
AnimatedListItem只有一个AnimationController,且动画时长仅 400ms,完成后停止 tick AnimatedBuilder只重建自身子树,不触发父级 widget 重建- 预构建 child 避免重复创建 widget
在鸿蒙 OHOS 真机上测试,20 个列表项的错峰动画帧率稳定在 60fps,无任何卡顿。
进阶技巧:交错动画
如果想让透明度动画和位移动画有不同的时序,可以组合两个 Tween 并设置不同的曲线区间:
dart
// 透明度先于位移完成
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.6, curve: Curves.easeOut), // 前60%时间完成
),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 1.0, curve: Curves.easeOutCubic), // 完整周期
),
);
Interval 允许你为同一个 AnimationController 的不同 property 分配不同的时间区间,创建更复杂的交错节奏。
总结
延时错峰入场动画的实现可以浓缩为三要素:
- AnimationController:为每个列表项创建独立控制器,控制 400ms 的动画周期
- Future.delayed + mounted 检查 :
index * delay实现错峰启动,mounted防止异步回调崩溃 - Opacity + Transform.translate :透明度淡入 + Y 轴位移组合,
easeOut曲线模拟减速运动
封装后的 AnimatedListItem 仅仅是对 AnimationController 的一层薄封装,但它为整个应用的列表注入了生命力------而这生命力完全由 Flutter 框架层提供,在鸿蒙平台上零额外适配。
完整项目代码见:todo_flutter_harmony