Flutter提供了一些非常强大而简单的动画API,我们可以用它们来取悦我们的用户。
在本教程中,我们将通过使用AnimationController 、AnimationBuilder 、手势检测器和自定义3D矩阵变换构建一个交互式翻页小部件来详细探索这些API。其结果将是一个自定义的PageFlipBuilder widget,它将所有的复杂性隐藏在一个易于使用的API后面。
最后我还会分享一个新的Flutter包,你可以用它来翻阅任何大小的页面、卡片和小工具。🚀
注意:本教程假定你已经熟悉了Flutter动画的基本知识。如果你是这个话题的新手,请查看Flutter网站上的动画介绍页面。
Flutter翻页过渡
实时Flutter网页演示
在我们开始之前,先玩一下这个实时演示。
您可以向左/右拖动来翻转页面。很酷吧?😎
好了,让我们来看看如何构建这个!
初始项目
我们将重点关注如何实现翻页过渡,而不是页面本身。
注意:我们将要看到的所有代码都使用了null-safety,这在最新的Flutter稳定版中已经可用。
要想继续学习,请抓紧启动项目,该项目包含两个widget类,名为LightHomePage 和DarkHomePage 。
而我们将从一个简单的应用程序开始,在一个黑色的Container 内使用LightHomePage() ,作为我们的MaterialApp 的主页。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Container(
// add a black background that will prevent flickering on Android when the page flips
color: Colors.black,
LightHomePage(),
),
);
}
}
class LightHomePage extends StatelessWidget {
const LightHomePage({Key? key, this.onFlip}) : super(key: key);
final VoidCallback? onFlip;
...
}
class DarkHomePage extends StatelessWidget {
const DarkHomePage({Key? key, this.onFlip}) : super(key: key);
final VoidCallback? onFlip;
...
}
正如我们所看到的,这两个页面都包含一个onFlip 回调,我们将用它来程序化地触发一个翻页。
如果运行这个应用程序,我们应该得到以下结果。
头部页面
我们的目标是创建一个PageFlipBuilder widget,可以在两个页面之间以编程方式(通过回调)和互动方式(通过用户手势)进行翻转。
一个好的开始是决定这个小部件应该有什么API。
PageFlipBuilder的API设计
为了使我们的PageFlipBuilder ,前面和后面的小部件应该作为参数给出。
所以我们可能会想创建一个像这样的API。
PageFlipBuilder(
front: LightHomePage(),
back: DarkHomePage(),
)
但是由于在任何时候都只有一个页面在屏幕上可见,使用两个WidgetBuilder 参数并让PageFlipBuilder 调用正确的参数会更有表现力。
PageFlipBuilder(
frontBuilder: (_) => LightHomePage(),
backBuilder: (_) => DarkHomePage(),
)
但是,我们的页面的onFlip() 回调呢?
LightHomePage(
onFlip: /* what goes here? */
)
我们应该使用这个回调来调用PageFlipBuilder 内的某种flip() 方法。
正如我们将看到的,PageFlipBuilder 将是一个StatefulWidget ,所以我们可以使用一个全局键,并像这样在MyApp 中挂钩。
class MyApp extends StatelessWidget {
// 1. declare a GlobalKey that we will use to reference the PageFlipBuilder state
final pageFlipKey = GlobalKey<PageFlipBuilderState>();
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Container(
// add a black background that will prevent flickering on Android when the page flips
color: Colors.black,
child: PageFlipBuilder(
// 2. pass the key
key: pageFlipKey,
frontBuilder: (_) => LightHomePage(
// 3a. call an internal `flip()` method on the state class
onFlip: () => pageFlipKey.currentState?.flip(),
),
backBuilder: (_) => DarkHomePage(
// 3b. call an internal `flip()` method on the state class
onFlip: () => pageFlipKey.currentState?.flip(),
),
),
),
);
}
}
当然,在这个阶段,这些代码都不能编译,因为我们甚至还没有创建我们的PageFlipBuilder 。
所以让我们来处理这个问题。
最重要的提示:事先考虑好你希望你的小部件有哪些API。这将节省你以后的时间,并使小组件更容易使用。
PageFlipBuilder小组件
让我们添加一个具有以下内容的page_flip_builder.dart 文件。
import 'package:flutter/material.dart';
class PageFlipBuilder extends StatefulWidget {
const PageFlipBuilder({
Key? key,
required this.frontBuilder,
required this.backBuilder,
}) : super(key: key);
final WidgetBuilder frontBuilder;
final WidgetBuilder backBuilder;
@override
PageFlipBuilderState createState() => PageFlipBuilderState();
}
// Note: there's no underscore here as we want this State subclass to be public.
// This is so that we can call the flip() method from the outside.
class PageFlipBuilderState extends State<PageFlipBuilder> {
void flip() {
// TODO: Implement
}
@override
Widget build(BuildContext context) {
// TODO: Replace with page flip code
return widget.frontBuilder(context);
}
}
如果我们在我们的main.dart 文件中导入page_flip_builder.dart ,我们看到代码现在可以编译了,因为PageFlipBuilder 定义了我们所使用的所有参数。
而如果我们运行这个应用程序,我们看到我们仍然显示LightHomePage ,因为build() 方法返回frontBuilder(context) 。
头部页面
是时候解决如何为事物做动画了。
用AnimationController和AnimatedBuilder做动画
为了使动画工作,我们需要两个成分。
- 一个
AnimationController来控制翻转过渡 - 一个
AnimatedBuilderwidget,根据动画值,用一个自定义的三维变换来旋转前后页面。
正如我们将看到的,我们可以通过将AnimationController 作为输入传给AnimatedBuilder 来获得我们想要的效果。
AnimationController和AnimatedBuilder之间的互动
所以我们接下来的两个目标是。
- 设置一个
AnimationController来控制翻转过渡 - 使用
AnimatedBuilder编写一些自定义代码来获得旋转效果
所以让我们看看如何做到这一点,从AnimationController 代码开始。
设置AnimationController
现在,我们要以编程方式翻转页面。在下一个教程中,我们将使用GestureDetector ,使事情变得互动。
在这个阶段,我们需要做两件事。
- 添加一个
_showFrontSide状态变量,它将告诉我们应该显示哪一页 - 设置我们的
AnimationController
这就是我们需要的代码。
class PageFlipBuilderState extends State<PageFlipBuilder>
// 1. Add SingleTickerProviderStateMixin
with SingleTickerProviderStateMixin {
// 2. Add state telling us which page we should show
bool _showFrontSide = true;
// 3. Our AnimationController
late final AnimationController _controller;
@override
void initState() {
// 4. Create the AnimationController
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// 5. Add a status listener
_controller.addStatusListener(_updateStatus);
super.initState();
}
@override
void dispose() {
// 6. Clean things up when the widget is removed
_controller.removeStatusListener(_updateStatus);
_controller.dispose();
super.dispose();
}
void _updateStatus(AnimationStatus status) {
// 7. Toggle the state then a forward or reverse animation is complete
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
setState(() => _showFrontSide = !_showFrontSide);
}
}
void flip() {
// 8. Forward or reverse the controller depending on the state
if (_showFrontSide) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
// TODO: Replace with page flip code
return frontBuilder(context);
}
}
在这个阶段,我们的应用程序看起来仍然是一样的。但是我们可以添加一个监听器,在initState() 里面打印我们的AnimationController 的值。
// TODO: Temporary code, remove me
_controller.addListener(() {
print('value: ${_controller.value}');
});
如果我们热重启并按下LightHomePage 上的翻转按钮,我们会得到以下控制台输出。
flutter: value: 0.0
flutter: value: 0.066666
flutter: value: 0.099998
flutter: value: 0.133334
...
many more lines
...
flutter: value: 0.933332
flutter: value: 0.966666
flutter: value: 0.999998
flutter: value: 1.0
由于我们在动画结束时在_updateStatus() 内切换了_showFrontSide ,我们可以再次按下翻转按钮,看到以相反的顺序打印的值(从1.0到0.0)。
这证实了AnimationController 正在做正确的事情,我们可以用它来驱动我们的翻页动画。
所以我们可以删除临时监听器代码并继续前进。
添加翻页旋转代码
早些时候我们说过,我们将使用一个AnimatedBuilder 来获得我们想要的效果。
虽然我们可以在我们的build() 方法中直接返回一个AnimatedBuilder ,但我们应该遵循单一责任原则,创建一个单独的widget来代替。
所以让我们这样做吧。
class AnimatedPageFlipBuilder extends StatelessWidget {
const AnimatedPageFlipBuilder({
Key? key,
required this.animation,
required this.showFrontSide,
required this.frontBuilder,
required this.backBuilder,
}) : super(key: key);
final Animation<double> animation;
final bool showFrontSide; // we'll see how to use this later
final WidgetBuilder frontBuilder;
final WidgetBuilder backBuilder;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
// TODO: implement me
},
);
}
}
这个新的widget类与我们的frontBuilder 和backBuilder 参数相同,PageFlipBuilder 。
但是它也有一个Animation<double> animation 变量,在build() 方法中作为输入传给AnimatedBuilder 。
有了这个,让我们更新一下PageFlipBuilderState 的build() 方法。
@override
Widget build(BuildContext context) {
return AnimatedPageFlipBuilder(
animation: _controller,
showFrontSide: _showFrontSide,
frontBuilder: widget.frontBuilder,
backBuilder: widget.backBuilder,
);
}
我们可以看到,_controller 被传递给animation 参数,其类型为Animation<double> 。这是允许的,因为AnimationController 在Flutter SDK中是这样定义的。
class AnimationController extends Animation<double> {}
使用Transform和Matrix4的旋转代码
我们将在一分钟内更新AnimatedBuilder 的代码。
但首先,让我们来计算一下数学问题。
在我们目前的设置中,我们的动画总是有一个介于0.0和1.0之间的值,这对应于一个介于0.0和pi (180度)的旋转值。
为了得到我们想要的页面效果,我们必须这样做。
- 在动画值介于0.0和0.5之间时显示前页
- 在动画值介于0.5和1.0之间时显示后页
为了达到这个目的,我们需要在AnimatedBuilder 。
AnimatedBuilder(
animation: animation,
builder: (context, _) {
// this boolean tells us if we're on the first or second half of the animation
final isAnimationFirstHalf = animation.value.abs() < 0.5;
// decide which page we need to show
final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context);
// map values between [0, 1] to values between [0, pi]
final rotationValue = animation.value * pi;
// calculate the correct rotation angle depening on which page we need to show
final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue;
return Transform(
transform: Matrix4.rotationY(rotationAngle),
child: child,
alignment: Alignment.center,
);
},
)
这里有很多东西需要解读。
- 我们使用
isAnimationFirstHalf来决定调用哪个构建器**(正面或背面**),并获得将被呈现的childwidget - 我们计算旋转角度并将
Matrix4.rotationY(rotationAngle)传递给一个Transformwidget,以应用围绕Y轴的3D旋转。
如果我们现在运行应用程序,我们可以前后翻转页面。
无深度的翻页效果
不幸的是,这个结果缺乏 "深度",看起来不像是一个三维旋转。
为了解决这个问题,我们需要计算并应用一个 "倾斜 "值。
这个值在动画的开始和结束时应该是0.0。而前面和后面的小部件应该有一个相反的倾斜值。
让我们把一切放在一起。
AnimatedBuilder(
animation: animation,
builder: (context, _) {
// this boolean tells us if we're on the first or second half of the animation
final isAnimationFirstHalf = animation.value.abs() < 0.5;
// decide which page we need to show
final child = isAnimationFirstHalf ? frontBuilder(context) : backBuilder(context);
// map values between [0, 1] to values between [0, pi]
final rotationValue = animation.value * pi;
// calculate the correct rotation angle depening on which page we need to show
final rotationAngle = animation.value > 0.5 ? pi - rotationValue : rotationValue;
// calculate tilt
var tilt = (animation.value - 0.5).abs() - 0.5;
// make this a small value (positive or negative as needed)
tilt *= isAnimationFirstHalf ? -0.003 : 0.003;
return Transform(
transform: Matrix4.rotationY(rotationAngle)
// apply tilt value
..setEntry(3, 0, tilt),
child: child,
alignment: Alignment.center,
);
},
)
通过最新的变化,我们计算出倾斜度,并通过..setEntry(3, 0, tilt) ,改变Matrix4 对象的一个值来应用它。
用于我们的变换的结果Matrix4 ,看起来像这样。
带有Y旋转和倾斜的变换矩阵
你可以阅读这篇关于Matrix4和透视变换的文章,了解更多关于我们刚刚使用的数学知识。
如果我们现在运行这个应用程序,我们可以看到翻页的效果更有说服力了。
带有倾斜效果的翻页
很好的东西,我们现在可以在按下翻页按钮的时候向前和向后翻页了。
是时候拍拍自己的肩膀了。
总结
本教程的第一部分到此结束。
一路走来,我们已经学会了如何。
- 将一个
AnimationController作为输入传给一个AnimatedBuilderwidget - 使用
AnimatedBuilder和Transform小组件来实现三维旋转 - 将本地小部件的状态与
AnimationController的值同步。
虽然PageFlipBuilder 的内部相当复杂,但我们已经设计了一个API,使它从外部易于使用。
下一步是什么?
在第二部分中,我们将通过添加一个GestureDetector ,使我们的PageFlipBuilder 具有交互性。
如果你等不及了,想在你的应用程序中使用它,你可以到pub.dev去,在那里我已经把最终产品作为Flutter包发布了。
当然,您也可以在GitHub上查看源代码。
除了我们在这些教程中所涉及的内容外,page_flip_builder 包还提供了一些额外的功能。
- 围绕水平或垂直轴进行翻转
- 用它来翻转任何尺寸的小部件
- 可定制的倾斜和缩放参数
在Y轴和X轴上进行卡片翻转过渡
如果你最终使用它,请在Twitter上告诉我你的想法。
鸣谢
Flutter社区的这些文章帮助我了解了一些我的PageFlipBuilder 。
翻转动画文章使用了一个AnimatedSwicher widget,如果你不需要处理拖动手势,这使生活更容易。
但我想通过使动画具有互动性而使事情更进一步,并创建一个具有漂亮的API的包,你可以在你的应用程序中使用。
还有一件事
AnimationController,AnimatedBuilder, 和GestureDetector 一起使用时非常强大。
但在Flutter中,你可以用动画做的事情还有很多。
编码愉快!