Flutter动画开发这块,我一直觉得是个投入产出比很离谱的方向。你花半小时调一个弹性曲线,可能代码量不多,但用户打开 App 的第一印象会被直接拉高一截。尤其是现在大家都在聊 Flutter、交互、动效设计,一个按钮按下去有没有反馈、页面切换有没有过渡、加载状态是不是有呼吸感,这些细节直接决定了产品是“做完了”还是“做好了”。
这篇是我自己沉淀下来的一组 Flutter 动画实战案例,总共 10 种交互场景,全部是从真实业务里抽出来的需求,不是 PPT 上那种炫技 Demo。每种动画我都会讲清楚:这个动效解决了什么问题、底层用到了哪些 Flutter 动画机制、核心代码怎么写、有哪些坑是我踩过之后才明白的。适合刚接触 Flutter 动画的开发者,也适合已经写了几个页面但总觉得“差口气”的人参考。
1. 技术底座与动画思路
1.1 动画性能的关键:Flutter 渲染管线与 Impeller
聊 Flutter 动画之前,得先把渲染管线这层地基说清楚。Flutter 的动画之所以能跑得流畅,核心原因在于它的架构和传统原生 UI 不太一样:Flutter 的 UI 线程只负责构建 widget 树和 layer 树,真正的绘制和光栅化是在独立的 raster 线程完成的。这意味着动画每一帧的“计算 widget 状态”和“真正往屏幕上画像素”是并行的,只要每帧的构建时间控制在 16ms 以内,视觉上就是 60fps 的丝滑体验。
早期 Flutter 在 Android 上使用 Skia 引擎,有一个很典型的痛点:首次运行时 shader 编译会造成掉帧,用户滑动页面时会突然卡一下,之后又恢复正常。后来 Flutter 团队推出了 Impeller 渲染引擎,核心思路很直接——全部 shader 在应用启动时预编译好,运行时不再有“边用边编译”的等待。现在的 Flutter 版本里,新项目基本默认走 Impeller 管线,在 iOS 上已经是稳定选项,Android 上的支持也逐步成熟。
如果你在跑动画时感觉首帧吃力,
可以手动开启 Impeller 对比效果:
flutter run --enable-impeller不过也要注意,Impeller 目前对 Shader 里某些高级特性的支持还在完善中。如果项目里大量使用了自定义 Shader,先小范围灰度验证,不要盲目全量切过去。
1.2 隐式动画与显式动画:什么时候用哪种
Flutter 动画基本可以分成两派:隐式动画和显式动画。
隐式动画的代表是一堆AnimatedXxx组件,比如AnimatedContainer、AnimatedOpacity、AnimatedPadding。你只需要告诉 Flutter“目标值是什么”,剩下的补间过程它帮你自动完成。比如一个容器从红色变蓝色,直接改color属性,加上AnimatedContainer包一层,颜色过渡就出来了。代码量极少,适合单一属性、简单状态切换的场景。
但隐式动画有一个硬伤:它只会让你指定目标值,中间过程你无法干预。弹跳曲线想要更夸张一点?时间节奏想分两段?手势拖到一半停住?这些隐式动画都做不了。所以做交互动效,主力一定是显式动画——也就是AnimationController。
AnimationController本质上是一个由Ticker驱动的数值生成器,每一帧回调里,它的value会从 0.0 变化到 1.0。你可以把value理解成时间的进度条,再通过Tween把进度映射到实际的属性变化上。比如:
late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), ); late final Animation<double> _scale = Tween(begin: 0.8, end: 1.0) .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));Tween负责“怎么变”,Curve负责“按什么节奏变”,Controller负责“什么时候变、变多久”。三者组合起来,基本能覆盖 90% 的交互场景。
SingleTickerProviderStateMixin是 State 里创建 Controller 时必须加的 mixin,作用是把 State 本身注册成 TickerProvider,为 Controller 提供 vsync 信号。这个必须写,不然 Controller 不知道跟谁要“心跳”。
1.3 多文件拆解:用 part 组织动画模块
动画多了以后,一个 Widget 文件会迅速膨胀。这里要提一个 Flutter 里容易被忽略的语法:part和part of。
part可以把一个文件的内容“拼”进另一个文件里编译。比如我把所有动画的子组件放进like_button_animation.dart文件:
part of 'like_button_page.dart'; class _ParticleWidget extends StatelessWidget { // 粒子组件实现 }然后主页面文件头部声明:
import 'package:flutter/material.dart'; part 'like_button_animation.dart';这样做的好处是:主 list 文件不用写一堆 import,组织方式清晰,编译结果等同于一个文件。当然,现在的 Flutter 中也完全可以拆成独立库文件然后 import,效果接近。part更接近“把代码物理切分但逻辑上还在同一文件”的思路,适合单体页面内聚性强的场景,比如多个只服务于当前页面的动画小组件。用不用看团队规范,但至少你要知道这个机制,否则看别人项目时容易一脸懵。
2. 10 种炫酷交互动画实战
2.1 点赞反馈动画:弹性按钮与粒子扩散
先来一个最日常的需求:信息流列表里的点赞按钮。用户点击心形图标后,除了图标从灰色变红,还要有一个“被按下去又弹回来”的反馈,外加小粒子向四周散开。这样用户能第一时间感知到“我点成功了”,而不是干巴巴地变色。
核心拆解是两个子动画:图标本身的弹性缩放,以及粒子的扩散淡出。弹性部分用Curves.elasticOut,这个曲线自带“冲过头再回弹”的效果,很像真实物理按钮的阻尼感。不要用默认的easeOut,那样虽然顺滑但没有“按下去”的反馈。
粒子扩散我习惯用Stack定位,每个粒子的初始位置在按钮中心,随着动画进度向外偏移,同时透明度降到 0。把粒子数量控制在 6 到 10 个,太多反而显得乱。
class LikeButton extends StatefulWidget { const LikeButton({super.key}); @override State<LikeButton> createState() => _LikeButtonState(); } class _LikeButtonState extends State<LikeButton> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), ); late final Animation<double> _scale = Tween(begin: 0.8, end: 1.0) .chain(CurveTween(curve: Curves.elasticOut)) .animate(_controller); late final Animation<double> _particleFade = Tween(begin: 1.0, end: 0.0) .chain(CurveTween(curve: const Interval(0.4, 0.9, curve: Curves.easeOut))) .animate(_controller); bool _liked = false; void _handleTap() { setState(() => _liked = !_liked); _controller.forward(from: 0); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: SizedBox( width: 48, height: 48, child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Stack( alignment: Alignment.center, children: [ // 粒子层 if (_liked) ...List.generate(8, (i) { final angle = (i / 8) * 3.14159 * 2; final radius = 28 * _controller.value; return Opacity( opacity: _particleFade.value, child: Transform.translate( offset: Offset( radius * math.cos(angle), radius * math.sin(angle), ), child: Container( width: 4, height: 4, decoration: const BoxDecoration( color: Colors.pinkAccent, shape: BoxShape.circle, ), ), ), ); }), // 图标层 Transform.scale( scale: _scale.value, child: Icon( _liked ? Icons.favorite : Icons.favorite_border, color: _liked ? Colors.pinkAccent : Colors.grey, size: 32, ), ), ], ); }, ), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }两个细节要注意。第一,粒子扩散不能和图标缩放同时开始,先用Interval把粒子这组动画延迟到整体进度 0.4 之后,视觉逻辑才是“先按下、再向外炸开”。第二,from: 0这个参数,每次点击都强制控制器从 0 帧开始,否则快速连点时动画会接着上次进度走,出现“没弹起来就消失”的情况。
2.2 手势跟手半屏弹层:阻尼跟随
第二种场景非常常见:外卖点餐页的“选规格”半屏弹层、地图页底部的卡片抽屉。它们的核心要求不是“自动滑上来”,而是用户手指往下拖时,弹层要实时跟手,松手后再根据位置决定收起还是展开。
跟手动画的关键点在于:动画进度不是由时间驱动,而是由手指的位移驱动。做法是GestureDetector里监听垂直拖动距离,把“拖了多少像素”换算成AnimationController.value的增减。
class DragSheet extends StatefulWidget { const DragSheet({super.key}); @override State<DragSheet> createState() => _DragSheetState(); } class _DragSheetState extends State<DragSheet> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); double _dragStartValue = 0; void _onVerticalDragStart(DragStartDetails details) { _dragStartValue = _controller.value; } void _onVerticalDragUpdate(DragUpdateDetails details) { final delta = details.primaryDelta ?? 0; final ratio = delta / 400; setState(() { _controller.value = (_dragStartValue + ratio).clamp(0.0, 1.0); }); } void _onVerticalDragEnd(DragEndDetails details) { if (_controller.value > 0.5) { _controller.animateTo(1.0, curve: Curves.easeOut); } else { _controller.animateTo(0.0, curve: Curves.easeOut); } } @override Widget build(BuildContext context) { return Align( alignment: Alignment.bottomCenter, child: GestureDetector( onVerticalDragStart: _onVerticalDragStart, onVerticalDragUpdate: _onVerticalDragUpdate, onVerticalDragEnd: _onVerticalDragEnd, child: AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.translate( offset: Offset(0, 500 * (1 - _controller.value)), child: child, ); }, child: Container( height: 500, width: double.infinity, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), ), ), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }这里有个比例换算问题:400是我预设的“全行程距离”,也就是手指需要拖动这么多像素才能让弹层从完全收起变成完全展开。这个值可以从弹层高度算出来,比如弹层 500dp、内容顶部有 50dp 的留白,那拖拽 450dp 左右触发收起比较合理。
松手后的回弹规则也讲究。不是所有松手都自动收起,很多弹层是往下滑超过一半才收起,否则回弹到展开状态,避免用户误触。代码里以 0.5 作为分界线,实际项目里可以调高到 0.6,让收起操作更“故意”,减少误操作感。
2.3 旋转辉光 Loading 动画
业务里永远缺不了 Loading。扫一扫识别中、图片上传等待、网络请求处理,这些场景都需要一个“让用户知道系统还在干活”的反馈。有时候做不到多华丽,但一个好看的 Loading 能显著降低用户焦虑。
常规做法是转圈,简单但没什么气质。我常用的方案是:用CustomPainter画一个圆环,圆环的弧长随进度周期性伸缩,同时整个环缓慢旋转,再叠加一层半透明的大半径圆环做“辉光”效果。
class GlowLoading extends StatefulWidget { const GlowLoading({super.key}); @override State<GlowLoading> createState() => _GlowLoadingState(); } class _GlowLoadingState extends State<GlowLoading> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(seconds: 1), )..repeat(); @override Widget build(BuildContext context) { return RepaintBoundary( child: SizedBox( width: 64, height: 64, child: AnimatedBuilder( animation: _controller, builder: (context, child) { return CustomPaint( painter: _GlowPainter(progress: _controller.value), ); }, ), ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } } class _GlowPainter extends CustomPainter { final double progress; _GlowPainter({required this.progress}); @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final radius = size.width / 2 - 8; for (int i = 0; i < 3; i++) { final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 4.0 ..strokeCap = StrokeCap.round ..color = Colors.blueAccent.withOpacity(0.7 - i * 0.2); final startAngle = (progress * 3.14159 * 4) + i * 2.094; final sweepAngle = (0.5 + 0.4 * math.sin(progress * 3.14159 * 2 + i)) * 3.14159; canvas.drawArc( Rect.fromCircle(center: center, radius: radius - i * 6), startAngle, sweepAngle, false, paint, ); } } @override bool shouldRepaint(covariant _GlowPainter oldDelegate) { return oldDelegate.progress != progress; } }代码里的关键思路是三条弧错开角度和长度,每条弧的长度用正弦函数周期性变化,组合起来就像呼吸一样。RepaintBoundary在这里非常重要:它把动画刷新范围限制在 Loading 组件本身,避免整个页面的其他部分被连带重绘。
还有一点经验之谈:Loading 动画一定要配合超时逻辑。网络请求长时间没返回时,不能让它无限转下去。我会在 Loading 超过阈值后切到错误提示面板,配合一个简单的横向抖动动画提示“网络不给力”。这比一直转圈好得多。
2.4 Tab 切换下划线滑移动画
首页级 App 几乎都有顶部分类 Tab,比如“推荐 / 视频 / 图文 / 专栏”。Tab 切换时,下划线如果能跟着滑动而不是瞬间跳过去,整个页面的质感会明显提升。
实现思路是把下划线做成一个从旧位置滑到新位置的矩形。关键步骤是拿到每个 Tab 的宽度和横坐标。我习惯在 Tab 组件上绑定GlobalKey,切换时通过RenderBox获取位置信息:
class SlidingTabBar extends StatefulWidget { final List<String> tabs; const SlidingTabBar({super.key, required this.tabs}); @override State<SlidingTabBar> createState() => _SlidingTabBarState(); } class _SlidingTabBarState extends State<SlidingTabBar> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); late final Animation<double> _slide = CurvedAnimation(parent: _controller, curve: Curves.easeInOut); final List<GlobalKey> _tabKeys = []; int _currentIndex = 0; int _targetIndex = 0; // 记录滑动起止位置 double _startLeft = 0; double _startWidth = 0; double _endLeft = 0; double _endWidth = 0; @override void initState() { super.initState(); _tabKeys.addAll(List.generate(widget.tabs.length, (_) => GlobalKey())); } void _onTap(int index) { if (index == _currentIndex) return; _measurePositions(); setState(() { _targetIndex = index; _startLeft = _leftForIndex(_currentIndex); _startWidth = _widthForIndex(_currentIndex); _endLeft = _leftForIndex(_targetIndex); _endWidth = _widthForIndex(_targetIndex); }); _controller.forward(from: 0); setState(() => _currentIndex = index); } double _leftForIndex(int index) { final renderBox = _tabKeys[index].currentContext?.findRenderObject() as RenderBox?; if (renderBox == null) return 0; return renderBox.localToGlobal(Offset.zero).dx; } double _widthForIndex(int index) { final renderBox = _tabKeys[index].currentContext?.findRenderObject() as RenderBox?; return renderBox?.size.width ?? 0; } void _measurePositions() {} @override Widget build(BuildContext context) { return SizedBox( height: 44, child: Stack( children: [ Positioned( left: _startLeft + (_endLeft - _startLeft) * _slide.value, width: _startWidth + (_endWidth - _startWidth) * _slide.value, top: 0, bottom: 0, child: Container( margin: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: Colors.blue, borderRadius: BorderRadius.circular(4), ), ), ), Row( children: List.generate(widget.tabs.length, (index) { return Expanded( child: GestureDetector( key: _tabKeys[index], onTap: () => _onTap(index), child: Center( child: Text( widget.tabs[index], style: const TextStyle(fontSize: 16), ), ), ), ); }), ), ], ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }底色关键在Positioned里同时补间 left 和 width,下划线从旧 tab 的位置和宽度,平滑过渡到新 tab。注意要先更新_currentIndex再触发动画,避免下一次测量时拿到的是新位置。
这套动画和bloc状态管理联动也很自然:当前选中的 Tab 是一个状态,Cubit 里emit新的 index 后,监听者只需调用_controller.forward(from: 0)启动动画即可,不需要在 build 里反复 setState。
2.5 列表增删与 Hero 过场动画
列表页是移动端的主战场景之一。电商购物车勾选删除、社交动态点赞后收起、订单列表点进详情,这些跳转和增删过程如果能有一层流畅的动画过渡,整体体验会上一个台阶。
先做列表项增删,最简单的工具是AnimatedList。它和普通ListView的区别是支持内容变化时的过渡动画:新建项“长大”进来,删除项“缩小消失”。删除操作要注意一点:不能像普通列表一样直接移除数据然后 setState,必须调用removeItem(index, builder),并传入一个动画 builder 来定义“消失”时的类型。
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>(); final List<String> _items = ['条目1', '条目2', '条目3', '条目4']; void _addItem() { final index = _items.length; _items.add('新条目'); _listKey.currentState?.insertItem(index); } void _removeItem(int index) { final removedItem = _items.removeAt(index); _listKey.currentState?.removeItem( index, (context, animation) => SizeTransition( sizeFactor: animation, child: ListTile( title: Text(removedItem), trailing: const Icon(Icons.delete_outline), ), ), ); }SizeTransition包裹删除项后,列表项高度会从 1 倍缩到 0,整体看起来像是被“抽走”了,而不是突然消失。
列表到详情页的跳转,我强烈推荐Hero动画。它的原理是:前后两个页面各自存在一个带相同tag的组件,系统自动在路由切换时给它们之间搭一条补间动画路径。最常见的是列表缩略图飞入详情页大图。
// 列表页 Hero( tag: 'order-${order.id}', child: CachedNetworkImage(imageUrl: order.coverUrl), ) // 详情页 Hero( tag: 'order-${order.id}', child: Image.network(order.coverUrl), )使用Hero最容易踩的坑是 tag 重复。只要列表同一屏里出现两个相同 tag 的 Hero,切换路由时动画就会抽搐甚至崩溃。解决方法是 tag 一定要带唯一标识,比如订单 ID、商品 ID,而不是写死一个字符串。
2.6 下拉刷新水滴回弹动画
下拉刷新是信息流标配。原生RefreshIndicator虽然开箱即用,但视觉上不够个性,很多 App 会把下拉指示器做成品牌 IP 或者水滴造型。这个方向的技术点在于:动画进度由“下拉位移”驱动,松手后再按物理弹簧的方式回弹。
实现上不用直接改造 RefreshIndicator,用ScrollNotification监听滚动偏移更灵活:
class WaterDropRefresh extends StatefulWidget { const WaterDropRefresh({super.key}); @override State<WaterDropRefresh> createState() => _WaterDropRefreshState(); } class _WaterDropRefreshState extends State<WaterDropRefresh> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); double _dragOffset = 0; bool _onNotification(ScrollNotification notification) { if (notification is ScrollUpdateNotification) { if (notification.metrics.extentBefore == 0 && notification.scrollDelta! > 0) { setState(() { _dragOffset = (_dragOffset + notification.scrollDelta!) .clamp(0.0, 120.0); _controller.value = _dragOffset / 120.0; }); } } else if (notification is ScrollEndNotification) { if (_dragOffset >= 80) { _controller.animateTo(1.0, curve: Curves.elasticOut, duration: const Duration(milliseconds: 600)); } else { _controller.animateBack(0, curve: Curves.easeOut, duration: const Duration(milliseconds: 200)); } _dragOffset = 0; } return false; } @override Widget build(BuildContext context) { return NotificationListener<ScrollNotification>( onNotification: _onNotification, child: CustomScrollView( slivers: [ SliverToBoxAdapter( child: AnimatedBuilder( animation: _controller, builder: (context, child) { final top = -60 + 60 * _controller.value; return Transform.translate( offset: Offset(0, top), child: Center( child: Transform.scale( scale: 0.8 + 0.2 * _controller.value, child: _WaterDrop(progress: _controller.value), ), ), ); }, ), ), SliverList.builder( itemCount: 30, itemBuilder: (context, index) { return ListTile( leading: CircleAvatar(child: Text('${index + 1}')), title: Text('下拉刷新测试条目 $index'), ); }, ), ], ), ); } @override void dispose() { _controller.dispose(); super.dispose(); } }水滴的绘制逻辑在_WaterDrop里,可以画一个中间宽、上下收窄的胶囊形,进度越往下拉水滴越“胖”,松手回弹时通过Curves.elasticOut产生轻微过冲,视觉上像真的有一滴水被拽下来又弹回去。
这里要强调一个关键认知:Curves.elasticOut并不适合所有场景,它的回弹幅度在 Flutter 里默认偏大。如果感觉“弹得太夸张像 bug”,可以用自定义曲线或者把事情拆成两段:先快速回弹 80%,再慢速补到 100%,观感更克制。
2.7 随机红包雨动画与 isolate 预计算
红包雨是营销活动页的常客,过年、周年庆、签到领奖都喜欢用。普通人都能想到的做法是:用CustomPainter画一堆红包,每帧更新坐标往下落。但如果直接在 UI 线程里生成一堆随机路径,帧率很容易在粒子数量变多后崩掉。
我这里有一个很实用的方案:事先用compute(也就是 isolate 的封装接口)在后台线程生成所有红包的随机路径,主线程只负责读取路径并绘制。这样随机数生成、路径计算的耗时完全不会阻塞 UI 线程。
class ParticlePath { final double startX; final double startY; final double endY; final double drift; final double speed; const ParticlePath({ required this.startX, required this.startY, required this.endY, required this.drift, required this.speed, }); } List<ParticlePath> generateRedPackets(int count) { final random = math.Random(); return List.generate(count, (index) { return ParticlePath( startX: random.nextDouble() * 400, startY: -50 - random.nextDouble() * 200, endY: 800 + random.nextDouble() * 200, drift: (random.nextDouble() - 0.5) * 80, speed: 0.2 + random.nextDouble() * 0.4, ); }); }UI 侧调用:
final List<ParticlePath> paths = await compute(generateRedPackets, 40);然后启动一个repeat的AnimationController,在每一帧根据 controller.value 计算出每个红包的实时位置,交给CustomPainter绘制。
使用compute要注意一点:传入和返回的数据必须是可跨 isolate 传递的简单对象。ParticlePath这种全是原始字段的纯数据类没问题,但如果参数是 Flutter 自带的Color、Offset等对象,需要先转成基本类型。这就是为什么我把红包路径数据设计成纯 double 而不是直接传Offset,多一步转换是为了换取跨 isolate 的兼容性。
红包雨的性能优化空间还很大:粒子数量控制在 30 到 60 之间,超过 80 后会明显增加光栅化压力;红包图片不要用整张高清图,用预裁剪的小图才能保证内存和 CPU 开销不过载。
2.8 相机快门闪光动画与原生震动联动
App 内拍照,尤其是扫一扫、证件照这类高频功能,点按快门时如果没有反馈会显得很“硬”。原生相机 App 的快门体验是:屏幕闪一下白、轻微缩放、同时伴随震动。在 Flutter 里要完整复刻这组体验,就要做到动画层 + 原生能力联动。
动画层比较简单:一个白色蒙层从透明度 0.8 降到 0,配合整页轻微缩放。我用TweenSequence把透明度变化拆成两段:先快速冲到 0.6 再渐隐到 0,模拟真实快门“开合”的闪现感。
震动部分要走原生能力。Flutter 的MethodChannel就是用来和原生代码通信的桥梁:
import 'package:flutter/services.dart'; class HapticService { static const _channel = MethodChannel('com.example.app/haptic'); static Future<void> lightImpact() async { try { await _channel.invokeMethod('vibrate', {'mode': 'light'}); } on PlatformException catch (e) { debugPrint('调用原生震动失败: ${e.message}'); } } }Android 侧,在主 Activity 里注册对应 channel 并调用振动服务:
class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "com.example.app/haptic" ).setMethodCallHandler { call, result -> if (call.method == "vibrate") { val vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate( VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE) ) } else { @Suppress("DEPRECATION") vibrator.vibrate(60) } result.success(null) } else { result.notImplemented() } } } }Channel 命名规范建议用“包名 + 功能名”,比如com.example.app/haptic,避免多个插件之间命名冲突。调用原生方法要包一层 try-catch,因为原生侧可能抛异常,Flutter 侧不接住会导致日志刷屏。
快门动画和震动必须做到“同帧触发”:震动调用放在动画启动的下一个 microtask 里,不能等快门动画结束再震,否则体验会慢半拍。实测下来,60ms 的震动时长在快门上比较合适,太长会拖沓,太短感知不到。
2.9 结算页金额数字滚动动画
电商结算页的优惠金额、满减计算、会员折扣,这几类数字的变化如果只是突然跳变,用户会缺少“这笔优惠是真的”的感知。数字滚动动画要解决的就是:让金额的变化过程可见,让优惠“看得见”。
Flutter 的Tween<int>默认是不存在的,Tween只能补间数值类型。所以我写了一个自定义的IntTween:
class IntTween extends Tween<int> { IntTween({required int begin, required int end}) : super(begin: begin, end: end); @override int lerp(double t) { return (begin! + (end! - begin!) * t).round(); } }配合一个 AnimationController,在动画过程中把IntTween的中间值格式化成金额字符串:
late final AnimationController _amountController = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); late final Animation<int> _amountAnimation = IntTween(begin: _oldAmount, end: _newAmount).animate( CurvedAnimation(parent: _amountController, curve: Curves.easeOut), ); // 在 build 里 AnimatedBuilder( animation: _amountController, builder: (context, child) { final formatted = NumberFormat('#,###.##').format( _amountAnimation.value / 100, ); return Text( '¥$formatted', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold), ); }, );金额通常用“分”为单位存整数,展示时才转成元,避免浮点数精度问题。NumberFormat会自动加上千分位逗号,做人民币展示时必须注意。
数字滚动还有一个更高阶的玩法:把每个数字做成单独的“滚动条”,像老虎机一样逐个数字翻转。做法是把金额拆成每一位数字,每一位都用一个小的IntTween去补间,动画速度可以根据位数错开,从低位到高位依次结束。这个效果在结算页上非常像样,但代码量会多一些。我的建议是先用整体数字补间把功能上线,之后再考虑逐位滚动,毕竟结算页的核心是“清晰准确”,不是炫技。
2.10 3D 翻转卡片与语音声波动画
最后这个案例是典型的综合应用:智能设备控制中心有一张卡片,正面显示设备名称、开关状态,点击后卡片 3D 翻转到背面,背面是语音控制历史,并且播放语音时会实时显示声波动画。
3D 翻转的核心是用Transform+Matrix4的透视投影。注意光旋转还不够,直接rotateY出来的效果很“平”,因为没有透视,人眼看不出近大远小的立体感。需要给矩阵设置一个透视入口:
Transform( transform: Matrix4.identity() ..setEntry(3, 2, 0.0012) ..rotateY(math.pi * _controller.value), alignment: Alignment.center, child: _controller.value < 0.5 ? _frontPanel() : _backPanel(), )setEntry(3, 2, 0.0012)这行就是透视系数。值越大,旋转时近处越放大、远处越缩小的效果越明显。但系数不能太大,超过 0.005 会出现明显的扭曲变形。翻转接近 90 度时,看到的是“纸片立起来”的侧面,这时候面板透明度应该渐变成 0 再显示背面,我用Opacity控制前后面板在 0.5 附近切换,避免穿帮。
声波动画我配合flutter_tts插件来做。TTS 播放语音时,通过setHandler回调拿到当前音量或播放状态,再驱动一个柱状图CustomPainter,每根柱子的高度是音量乘以该柱子的随机权重:
class WaveformPainter extends CustomPainter { final double volume; WaveformPainter({required this.volume}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.blueAccent ..strokeWidth = 4 ..strokeCap = StrokeCap.round; const int barCount = 20; for (int i = 0; i < barCount; i++) { final x = (size.width - 20) / (barCount - 1) * i + 10; final heightRatio = 0.2 + 0.8 * (math.sin(i * 0.8) * 0.5 + 0.5) * volume; final barHeight = size.height * heightRatio; canvas.drawLine( Offset(x, size.height / 2 - barHeight / 2), Offset(x, size.height / 2 + barHeight / 2), paint, ); } } @override bool shouldRepaint(covariant WaveformPainter oldDelegate) { return oldDelegate.volume != volume; } }声波动画的坑在于:音量回调频率可能和 UI 帧率不同步,有时候回调一秒只有几次,动画看起来一卡一卡。解决办法是拿到音量后,用一个短时间的AnimationController把音量值做一次“抗锯齿”平滑过渡,让柱子是从当前高度平滑滑到新高度,而不是直接跳变。
3. 性能调优、工程组织与状态联动
3.1 动画卡顿排查与 RepaintBoundary
动画写完,第一件事不是看效果,而是看性能。很多动画在模拟器上丝滑,一到真机就掉帧,问题通常出在重绘范围太大。
Flutter 的重绘重不重,取决于两个东西:绘制区域的大小和绘制内容的复杂度。如果动画组件外层套了整个页面级的大容器,那么每次动画变化,整个页面都要重新绘制。解决方案是RepaintBoundary,它像一个“隔离区”,把动画组件的重绘限制在自身范围内,不向父级扩散。
使用场景和注意事项:
- 多个列表项里都有动画,每个列表项包一层
RepaintBoundary,避免一项动画导致整个列表重绘。 - 动画内容本身如果非常复杂(比如大量粒子),包上
RepaintBoundary后效果明显。 - 但
RepaintBoundary不是越多越好。它本质上创建了一个额外的合成层,层级过多会加大 GPU 合成压力。普通静态页面完全没必要包。
排查卡顿的正确姿势是打开 DevTools 的 Timeline,观察帧渲染耗时分布。如果Raster线程耗时高,说明是绘制阶段的问题,优先检查图片尺寸、渐变、模糊、裁剪;如果UI线程耗时高,优先检查是不是动画回调里做了重计算或者频繁 setState。
还有一个容易被忽略的点:动画过程中不要打印debugPrint。日志输出在 debug 模式下会拖慢帧率,release 包虽然没影响,但调试时容易误判性能问题。
3.2 动画工作流:从设计稿到像素级还原
动画开发不能光靠手感。没有规范的动画工作流,做出来的动效全凭个人审美,团队协作时很容易打架。我的习惯是分四步走。
第一步,先在原型工具(比如 Figma 或 AE)里把动效的“时间轴”定出来:元素从哪里来、到哪里去、持续多长时间、用什么曲线。这一步不要求精确到每一帧,但至少要明确“快慢轻重”的节奏。
第二步,把设计稿的关键帧截图导出,在 Flutter 里实现完初稿后,用半透明叠加对比截图,检查位移和缩放是否一致。如果设计师设计的动画是 300ms 渐入 + 200ms 停顿 + 150ms 移出,我在代码里也严格按这个时间拆成三段动画,而不是一个 Controller 一口气跑完。
第三步,参数统一。我建议在项目里抽一个AppDurations常量类:
abstract final class AppDurations { static const Duration fast = Duration(milliseconds: 150); static const Duration base = Duration(milliseconds: 250); static const Duration slow = Duration(milliseconds: 400); }所有动画时长从这三个基准值派生,页面与页面之间才不会出现“一个动画快一个动画慢”的割裂感。
第四步,走查。使用flutter run --profile跑真机,重点看两个场景:快速连续触发动画是否掉帧,以及动画中断(比如突然切后台再回来)后状态是否正确。这个流程走完,动画才算真正完成。
3.3 bloc 状态驱动动画:避免“面向 setState”动画
动画和状态管理的关系,很多人一开始是懵的。最常见的问题是:在build里根据状态直接创建或修改动画 Controller,导致每次状态变化,动画都被重新初始化,根本无法播放。
我推荐的做法是:动画 Controller 仍然放在 State 里,但由 bloc/cubit 的状态变化来触发。比如一个加载状态的三态切换:
- 状态从 idle 变为 loading 时,监听到变化后调用
_loadingController.forward()。 - 状态从 loading 变为 success 时,停止 loading 动画,切换成成功打勾动画。
这个模式的好处有几个:动画逻辑和 UI 状态完全解耦,业务上什么条件触发动画,在 cubit 的状态变化里能一眼看清;动画回放(比如从失败到重试)只需要重新forward(from: 0);测试也简单,因为 Controller 的调用时机是可预期的,而不是在 build 里随缘触发。
我在实际项目里甚至会把“动画指令”直接放进状态类里。比如:
sealed class PlayerState { const PlayerState(); } class PlayerLoading extends PlayerState { const PlayerLoading(); } class PlayerReady extends PlayerState { final String coverUrl; const PlayerReady(this.coverUrl); }UI 层BlocListener里监听PlayerLoading就触发 loading 动画,监听PlayerReady就停止 loading 并播放封面上浮动画。这样状态即动画指令,代码非常容易阅读。
3.4 流式响应与打字机动画:AI 对话里的实时渲染
现在 AI 对话类 App 越来越火,服务端通过 SSE(Server-Sent Events)流式返回大模型回答,客户端要做的是收到一段文字就渲染一段。这个场景的动画不是传统意义上的“补间”,而是一种“增量动画”:文字的可见长度持续增长,像有人在打给你看。
Flutter 里可以用StreamBuilder订阅 SSE 字符流,维护一个已展示字符串,并用一个AnimationController控制打字机的字符数。比如控制器进度从 0 到 1 对应“从 0 个字符显示到全部字符”:
final fullText = '这是模型返回的完整回答'; final visibleCount = (fullText.length * _typeController.value).round();实际场景更复杂:服务端是边生成边返回的,完整的文本在流结束时才存在。所以通常用一个累积的receiveStream,每来一个 chunk 就把当前已接收文本作为fullText的引用,然后让_typeController从当前可见字符数继续向前推进。
配合取消请求是关键。用户点击“停止生成”按钮时,不仅要关闭 HTTP 连接,还要让动画立即停止,保留当前已输出的内容作为“半截回答”。这里我踩过一次坑:只取消了请求,但动画 Controller 还在跑,结果文字继续乱跳。所以取消时记得_typeController.stop(),并清空未完成的计划。
踩过几次坑之后,我的经验是:SSE 打字机动画避免用Curves.easeOut,因为 AI 回答的节奏应该是均匀的,用非线性曲线反而会让人觉得“越打越慢”。用Curves.linear最稳,速度一致,也符合大模型“逐 token 输出”的观感。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
动画开发中的问题,大部分集中在几个固定套路里。我整理了一个自己常用的排查表:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 真机掉帧,模拟器流畅 | 重绘范围过大 | 检查是否缺少 RepaintBoundary,打开 DevTools 看 Raster 耗时 |
| 动画前几帧卡一下 | Shader 编译 | 开启 Impeller,或预热阶段提前跑一次动画 |
| 动画跑到一半突然跳变 | 状态被重建 | 检查 Controller 是否在 build 中被重新创建 |
| 动画结束后闪一下 | 首帧状态未初始化 | 给透明度/位移设置初始值,或监听首帧回调 |
| AnimatedList 删除项直接消失 | removeItem 里没有提供动画 builder | 正确包装 SizeTransition/FadeTransition |
| 数字滚动出现小数 | IntTween 取整方式不对 | 确保 lerp 返回 round() 后的整数 |
| 快速连点动画错乱 | Controller 没有重置 | 触发前强制 forward(from: 0) |
| 切换页面后还听到 TTS 声波动画 | 未在 dispose 里销毁 | 重写 dispose,停止 TTS 并释放监听 |
| 下载 SDK 慢或者在编译时频繁报错 | 环境变量未配置镜像源 | 设置FLUTTER_STORAGE_BASE_URL等环境变量为可信镜像地址 |
表格里最后一条顺便多说一句:flutter sdk 下载慢是新手最常见的问题,但解决方案无非是配好环境变量。装好之后跑flutter doctor -v验证,比反复卸载重装省事得多。
4.2 我个人踩过的坑和习惯
先说一个典型的坑:写动画的时候,Controller 忘记dispose。这看起来是小问题,但会导致页面退出后动画还在后台跑,内存和电量都会被消耗。Flutter 的State被销毁时不会自动清理 Controller,所以每个AnimationController必须在dispose方法里手动释放。我把这个习惯内化成了肌肉记忆:写 Controller 的同时,先把dispose写好,再写动画逻辑。
第二个坑是透明度的使用。很多动画效果需要让元素半透明,但Opacity组件会在计算时创建新的合成层。如果动画过程中opacity每帧都在变,频繁创建合成层的开销不小。替代方案是直接用Color.withOpacity控制颜色透明度,或者用AnimatedBuilder里根据动画值计算透明度值,而不是反复包Opacity。
第三个习惯是“动画参数必须留注释”。动画里的400毫秒、1.2倍缩放、0.0012透视值,这些数字在代码评审时别人看到都是一头雾水。我的做法是每个关键参数旁边写一句:“这里是参照设计稿 400ms 的滑入时长,保持和其他页面一致”。下次自己回来维护也不用重新猜。
整体上,我个人在实际操作中的体会是:动画做得好看的秘诀不在代码量,而在节奏感。同一个页面里,所有动画的时长和曲线要保持统一,重要操作用快速反馈(150ms 左右),转场类动效用慢一些(300ms),让用户能看清“发生了什么”。如果每个效果都想去抢注意力,结果就是页面处处在动,等于没动,还把用户搞晕。
这个内容后续还可以扩展的方向很多:把这 10 种动画提炼成团队内部的通用动画组件库,沉淀成模板到新项目里直接复用,能大幅度缩短后续页面的联调时间。