在上一篇文章中,我带你一起学习了 Flutter 中实现页面路由的两种方式:基本路由与命名路由,即手动创建页面进行切换,和通过前置路由注册后提供标识符进行跳转。除此之外,Flutter 还在这两种路由方式的基础上,支持页面打开和页面关闭传递参数,可以更精确地控制路由切换。
通过前面第[12](https://time.geekbang.org/column/article/110292)、[13](https://time.geekbang.org/column/article/110859)、[14](https://time.geekbang.org/column/article/110848)和[15](https://time.geekbang.org/column/article/111673)篇文章的学习,我们已经掌握了开发一款样式精美的小型 App 的基本技能。但当下,用户对于终端页面的要求已经不再满足于只能实现产品功能,除了样式美观之外,还希望交互良好、有趣、自然。
动画就是提升用户体验的一个重要方式,一个恰当的组件动画或者页面切换动画,不仅能够缓解用户因为等待而带来的情绪问题,还会增加好感。Flutter 既然完全接管了渲染层,除了静态的页面布局之外,对组件动画的支持自然也不在话下。
因此在今天的这篇文章中,我会向你介绍 Flutter 中动画的实现方法,看看如何让我们的页面动起来。
## Animation、AnimationController 与 Listener
动画就是动起来的画面,是静态的画面根据事先定义好的规律,在一定时间内不断微调,产生变化效果。而动画实现由静止到动态,主要是靠人眼的视觉残留效应。所以,对动画系统而言,为了实现动画,它需要做三件事儿:
1. 确定画面变化的规律;
2. 根据这个规律,设定动画周期,启动动画;
3. 定期获取当前动画的值,不断地微调、重绘画面。
这三件事情对应到 Flutter 中,就是 Animation、AnimationController 与 Listener:
1. Animation 是 Flutter 动画库中的核心类,会根据预定规则,在单位时间内持续输出动画的当前状态。Animation 知道当前动画的状态(比如,动画是否开始、停止、前进或者后退,以及动画的当前值),但却不知道这些状态究竟应用在哪个组件对象上。换句话说,Animation 仅仅是用来提供动画数据,而不负责动画的渲染。
2. AnimationController 用于管理 Animation,可以用来设置动画的时长、启动动画、暂停动画、反转动画等。
3. Listener 是 Animation 的回调函数,用来监听动画的进度变化,我们需要在这个回调函数中,根据动画的当前值重新渲染组件,实现动画的渲染。
接下来,我们看一个具体的案例:让大屏幕中间的 Flutter Logo 由小变大。
首先,我们初始化了一个动画周期为 1 秒的、用于管理动画的 AnimationController 对象,并用线性变化的 Tween 创建了一个变化范围从 50 到 200 的 Animaiton 对象。
然后,我们给这个 Animaiton 对象设置了一个进度监听器,并在进度监听器中强制界面重绘,刷新动画状态。
接下来,我们调用 AnimationController 对象的 forward 方法,启动动画:
~~~
class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
// 创建动画周期为 1 秒的 AnimationController 对象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 创建从 50 到 200 线性变化的 Animation 对象
animation = Tween(begin: 50.0, end: 200.0).animate(controller)
..addListener(() {
setState(() {}); // 刷新界面
});
controller.forward(); // 启动动画
}
...
}
~~~
需要注意的是,我们在创建 AnimationController 的时候,设置了一个 vsync 属性。这个属性是用来防止出现不可见动画的。vsync 对象会把动画绑定到一个 Widget,当 Widget 不显示时,动画将会暂停,当 Widget 再次显示时,动画会重新恢复执行,这样就可以避免动画的组件不在当前屏幕时白白消耗资源。
我们在一开始提到,Animation 只是用于提供动画数据,并不负责动画渲染,所以我们还需要在 Widget 的 build 方法中,把当前动画状态的值读出来,用于设置 Flutter Logo 容器的宽和高,才能最终实现动画效果:
~~~
@override
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Center(
child: Container(
width: animation.value, // 将动画的值赋给 widget 的宽高
height: animation.value,
child: FlutterLogo()
)));
}
~~~
最后,别忘了在页面销毁时,要释放动画资源:
~~~
@override
void dispose() {
controller.dispose(); // 释放资源
super.dispose();
}
~~~
我们试着运行一下,可以看到,Flutter Logo 动起来了:
:-: ![](https://img.kancloud.cn/c7/3f/c73f5a245ecea87be428a83634ec12db_636x1132.gif)
图 1 动画示例
我们在上面用到的 Tween 默认是线性变化的,但可以创建 CurvedAnimation 来实现非线性曲线动画。CurvedAnimation 提供了很多常用的曲线,比如震荡曲线 elasticOut:
~~~
// 创建动画周期为 1 秒的 AnimationController 对象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 创建一条震荡曲线
final CurvedAnimation curve = CurvedAnimation(
parent: controller, curve: Curves.elasticOut);
// 创建从 50 到 200 跟随振荡曲线变化的 Animation 对象
animation = Tween(begin: 50.0, end: 200.0).animate(curve)
~~~
运行一下,可以看到 Flutter Logo 有了一个弹性动画:
:-: ![](https://img.kancloud.cn/ce/0f/ce0f1ce6380329e3d9194518e2be2d05_640x1132.gif)
图 2 CurvedAnimation 示例
现在的问题是,这些动画只能执行一次。如果想让它像心跳一样执行,有两个办法:
1. 在启动动画时,使用 repeat(reverse: true),让动画来回重复执行。
2. 监听动画状态。在动画结束时,反向执行;在动画反向执行完毕时,重新启动执行。
具体的实现代码,如下所示:
~~~
// 以下两段语句等价
// 第一段
controller.repeat(reverse: true);// 让动画重复执行
// 第二段
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();// 动画结束时反向执行
} else if (status == AnimationStatus.dismissed) {
controller.forward();// 动画反向执行完毕时,重新执行
}
});
controller.forward();// 启动动画
~~~
运行一下,可以看到,我们实现了 Flutter Logo 的心跳效果。
:-: ![](https://img.kancloud.cn/a7/e5/a7e5b1fd635a557cb4289273bd299e48_636x1132.gif)
图 3 Flutter Logo 心跳
## AnimatedWidget 与 AnimatedBuilder
在为 Widget 添加动画效果的过程中我们不难发现,Animation 仅提供动画的数据,因此我们还需要监听动画执行进度,并在回调中使用 setState 强制刷新界面才能看到动画效果。考虑到这些步骤都是固定的,Flutter 提供了两个类来帮我们简化这一步骤,即 AnimatedWidget 与 AnimatedBuilder。
接下来,我们分别看看这两个类如何使用。
在构建 Widget 时,AnimatedWidget 会将 Animation 的状态与其子 Widget 的视觉样式绑定。要使用 AnimatedWidget,我们需要一个继承自它的新类,并接收 Animation 对象作为其初始化参数。然后,在 build 方法中,读取出 Animation 对象的当前值,用作初始化 Widget 的样式。
下面的案例演示了 Flutter Logo 的 AnimatedWidget 版本:用 AnimatedLogo 继承了 AnimatedWidget,并在 build 方法中,把动画的值与容器的宽高做了绑定:
~~~
class AnimatedLogo extends AnimatedWidget {
//AnimatedWidget 需要在初始化时传入 animation 对象
AnimatedLogo({Key key, Animation<double> animation})
: super(key: key, listenable: animation);
Widget build(BuildContext context) {
// 取出动画对象
final Animation<double> animation = listenable;
return Center(
child: Container(
height: animation.value,// 根据动画对象的当前状态更新宽高
width: animation.value,
child: FlutterLogo(),
));
}
}
~~~
在使用时,我们只需把 Animation 对象传入 AnimatedLogo 即可,再也不用监听动画的执行进度刷新 UI 了:\
~~~
MaterialApp(
home: Scaffold(
body: AnimatedLogo(animation: animation)// 初始化 AnimatedWidget 时传入 animation 对象
));
~~~
在上面的例子中,在 AnimatedLogo 的 build 方法中,我们使用 Animation 的 value 作为 logo 的宽和高。这样做对于简单组件的动画没有任何问题,但如果动画的组件比较复杂,一个更好的解决方案是,**将动画和渲染职责分离**:logo 作为外部参数传入,只做显示;而尺寸的变化动画则由另一个类去管理。
这个分离工作,我们可以借助 AnimatedBuilder 来完成。
与 AnimatedWidget 类似,AnimatedBuilder 也会自动监听 Animation 对象的变化,并根据需要将该控件树标记为 dirty 以自动刷新 UI。事实上,如果你翻看[源码](https://github.com/flutter/flutter/blob/ca5411e3aa99d571ddd80b75b814718c4a94c839/packages/flutter/lib/src/widgets/transitions.dart#L920),就会发现 AnimatedBuilder 其实也是继承自 AnimatedWidget。
我们以一个例子来演示如何使用 AnimatedBuilder。在这个例子中,AnimatedBuilder 的尺寸变化动画由 builder 函数管理,渲染则由外部传入 child 参数负责:
~~~
MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedBuilder(
animation: animation,// 传入动画对象
child:FlutterLogo(),
// 动画构建回调
builder: (context, child) => Container(
width: animation.value,// 使用动画的当前状态更新 UI
height: animation.value,
child: child, //child 参数即 FlutterLogo()
)
)
)
));
~~~
可以看到,通过使用 AnimatedWidget 和 AnimatedBuilder,动画的生成和最终的渲染被分离开了,构建动画的工作也被大大简化了。
## hero 动画
现在我们已经知道了如何在一个页面上实现动画效果,那么如何实现在两个页面之间切换的过渡动画呢?比如在社交类 App,在 Feed 流中点击小图进入查看大图页面的场景中,我们希望能够实现小图到大图页面逐步放大的动画切换效果,而当用户关闭大图时,也实现原路返回的动画。
这样的跨页面共享的控件动画效果有一个专门的名词,即“共享元素变换”(Shared Element Transition)。
对于 Android 开发者来说,这个概念并不陌生。Android 原生提供了对这种动画效果的支持,通过几行代码,就可以实现在两个 Activity 共享的组件之间做出流畅的转场动画。
又比如,Keynote 提供了的“神奇移动”(Magic Move)功能,可以实现两个 Keynote 页面之间的流畅过渡。
Flutter 也有类似的概念,即 Hero 控件。**通过 Hero,我们可以在两个页面的共享元素之间,做出流畅的页面切换效果。**
接下来,我们通过一个案例来看看 Hero 组件具体如何使用。
在下面的例子中,我定义了两个页面,其中 page1 有一个位于底部的小 Flutter Logo,page2 有一个位于中部的大 Flutter Logo。在点击了 page1 的小 logo 后,会使用 hero 效果过渡到 page2。
为了实现共享元素变换,我们需要将这两个组件分别用 Hero 包裹,并同时为它们设置相同的 tag “hero”。然后,为 page1 添加点击手势响应,在用户点击 logo 时,跳转到 page2:
~~~
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(// 手势监听点击
child: Hero(
tag: 'hero',// 设置共享 tag
child: Container(
width: 100, height: 100,
child: FlutterLogo())),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));// 点击后打开第二个页面
},
)
);
}
}
class Page2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Hero(
tag: 'hero',// 设置共享 tag
child: Container(
width: 300, height: 300,
child: FlutterLogo()
))
);
}
}
~~~
运行一下,可以看到,我们通过简单的两步,就可以实现元素跨页面飞行的复杂动画效果了!
:-: ![](https://img.kancloud.cn/c5/fe/c5fe68b6e627d8285ed6aadf932abcd6_636x1132.gif)
图 4 Hero 动画
## 总结
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
在 Flutter 中,动画的状态与渲染是分离的。我们通过 Animation 生成动画曲线,使用 AnimationController 控制动画时间、启动动画。而动画的渲染,则需要设置监听器获取动画进度后,重新触发组件用新的动画状态刷新后才能实现动画的更新。
为了简化这一步骤,Flutter 提供了 AnimatedWidget 和 AnimatedBuilder 这两个组件,省去了状态监听和 UI 刷新的工作。而对于跨页面动画,Flutter 提供了 Hero 组件,只要两个相同(相似)的组件有同样的 tag,就能实现元素跨页面过渡的转场效果。
可以看到,Flutter 对于动画的分层设计还是非常简单清晰的,但造成的副作用就是使用起来稍微麻烦一些。对于实际应用而言,由于动画过程涉及到页面的频繁刷新,因此我强烈建议你尽量使用 AnimatedWidget 或 AnimatedBuilder 来缩小受动画影响的组件范围,只重绘需要做动画的组件即可,要避免使用进度监听器直接刷新整个页面,让不需要做动画的组件也跟着一起销毁重建。
我把今天分享中所涉及的针对控件的普通动画,AnimatedBuilder 和 AnimatedWidget,以及针对页面的过渡动画 Hero 打包到了[GitHub](https://github.com/cyndibaby905/22_app_animation)上,你可以把工程下载下来,多运行几次,体会这几种动画的具体使用方法。
## 思考题
最后,我给你留下两个小作业吧。
~~~
AnimatedBuilder(
animation: animation,
child:FlutterLogo(),
builder: (context, child) => Container(
width: animation.value,
height: animation.value,
child: child
)
)
~~~
1. 在 AnimatedBuilder 的例子中,child 似乎被指定了两遍(第 3 行的 child 与第 7 行的 child),你可以解释下这么做的原因吗?
2. 如果我把第 3 行的 child 删掉,把 Flutter Logo 放到第 7 行,动画是否能正常执行?这会有什么问题吗?
- 前言
- 开篇词
- 预习篇
- 01丨预习篇 · 从0开始搭建Flutter工程环境
- 02丨预习篇 · Dart语言概览
- Flutter开发起步
- 03丨深入理解跨平台方案的历史发展逻辑
- 04丨Flutter区别于其他方案的关键技术是什么?
- 05丨从标准模板入手,体会Flutter代码是如何运行在原生系统上的
- Dart语言基础
- 06丨基础语法与类型变量:Dart是如何表示信息的?
- 07丨函数、类与运算符:Dart是如何处理信息的?
- 08丨综合案例:掌握Dart核心特性
- Flutter基础
- 09丨Widget,构建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我们是在说什么?
- 12丨经典控件(一):文本、图片和按钮在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 经典布局:如何定义子控件在父容器中排版位置?
- 15 丨 组合与自绘,我该选用何种方式自定义Widget?
- 16 丨 从夜间模式说起,如何定制不同风格的App主题?
- 17丨依赖管理(一):图片、配置和字体在Flutter中怎么用?
- 18丨依赖管理(二):第三方组件库在Flutter中要如何管理?
- 19丨用户交互事件该如何响应?
- 20丨关于跨组件传递数据,你只需要记住这三招
- 21丨路由与导航,Flutter是这样实现页面切换的
- Flutter进阶
- 22丨如何构造炫酷的动画效果?
- 23丨单线程模型怎么保证UI运行流畅?
- 24丨HTTP网络编程与JSON解析
- 25丨本地存储与数据库的使用和优化
- 26丨如何在Dart层兼容Android-iOS平台特定实现?(一)
- 27丨如何在Dart层兼容Android-iOS平台特定实现?(二)
- 28丨如何在原生应用中混编Flutter工程?
- 29丨混合开发,该用何种方案管理导航栈?
- 30丨为什么需要做状态管理,怎么做?
- 31丨如何实现原生推送能力?
- 32丨适配国际化,除了多语言我们还需要注意什么
- 33丨如何适配不同分辨率的手机屏幕?
- 34丨如何理解Flutter的编译模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通过工具链优化开发调试效率?
- 37丨如何检测并优化FlutterApp的整体性能表现?
- 38丨如何通过自动化测试提高交付质量?
- Flutter综合应用
- 39丨线上出现问题,该如何做好异常捕获与信息采集?
- 40丨衡量FlutterApp线上质量,我们需要关注这三个指标
- 41丨组件化和平台化,该如何组织合理稳定的Flutter工程结构?
- 42丨如何构建高效的FlutterApp打包发布环境?
- 43丨如何构建自己的Flutter混合开发框架(一)?
- 44丨如何构建自己的Flutter混合开发框架(二)?
- 结束语
- 结束语丨勿畏难,勿轻略