1. 简介
动画是改善应用用户体验、向用户传达重要信息,以及让应用更加精致且使用起来更愉快的绝佳方式。
Flutter 动画框架概览
Flutter 通过在每一帧中重新构建 widget 树的一部分来显示动画效果。它提供了预构建的动画效果和其他 API,可简化动画的创建和组合。
- 隐式动画是指会自动运行整个动画的预构建动画效果。当动画的目标值发生变化时,它会从当前值运行到目标值,并显示中间的每个值,以便 widget 顺畅地呈现动画效果。隐式动画的示例包括
AnimatedSize
、AnimatedScale
和AnimatedPositioned
。 - 显式动画也是预构建的动画效果,但需要
Animation
对象才能正常运行。例如SizeTransition
、ScaleTransition
或PositionedTransition
。 - Animation 是一个类,表示正在运行或已停止的动画,由表示动画正在运行的目标值的值和表示动画在任何给定时间在屏幕上显示的当前值的状态组成。它是
Listenable
的子类,会在动画运行期间状态发生变化时通知其监听器。 - AnimationController 是一种创建动画并控制其状态的方法。它的方法(例如
forward()
、reset()
、stop()
和repeat()
)可用于控制动画,而无需定义要显示的动画效果(例如缩放比例、尺寸或位置)。 - 补间用于在开始值和结束值之间插值值,并且可以表示任何类型,例如 double、
Offset
或Color
。 - 曲线用于调整参数随时间的变化率。在动画运行时,通常会应用缓动曲线,以在动画的开始或结束时加快或减慢变化率。曲线接受介于 0.0 到 1.0 之间的输入值,并返回介于 0.0 到 1.0 之间的输出值。
构建内容
在此 Codelab 中,您将构建一个包含各种动画效果和技巧的多选题知识问答游戏。
您将了解如何...
- 构建一个会为其大小和颜色添加动画效果的小组件
- 构建 3D 卡片翻转效果
- 使用动画软件包中的精美预构建动画效果
- 添加了对最新版 Android 上提供的预测性返回手势的支持
学习内容
在此 Codelab 中,您将学习:
- 如何使用隐式动画效果实现效果出色的动画,而无需大量代码。
- 如何使用显式动画效果,通过预构建的动画微件(例如
AnimatedSwitcher
或AnimationController
)配置自己的效果。 - 如何使用
AnimationController
定义自己的 widget 来显示 3D 效果。 - 如何使用
animations
软件包以最少的设置显示精美的动画效果。
所需条件
- Flutter SDK
- IDE,例如 VSCode 或 Android Studio / IntelliJ
2. 设置您的 Flutter 开发环境
您需要使用两款软件才能完成此 Codelab:Flutter SDK 和一款编辑器。
您可使用以下任一设备学习此 Codelab:
- 一台连接到计算机并设置为开发者模式的实体 Android(建议在第 7 步中实现预测性返回)或 iOS 设备。
- iOS 模拟器(需要安装 Xcode 工具)。
- Android 模拟器(需要在 Android Studio 中设置)。
- 浏览器(需要使用 Chrome,以便进行调试)。
- Windows、Linux 或 macOS 桌面计算机。您必须在打算部署到的平台上进行开发。因此,如果您要开发 Windows 桌面应用,则必须在 Windows 上进行开发,才能使用相应的构建链。如需详细了解针对各种操作系统的具体要求,请访问 docs.flutter.dev/desktop。
验证安装情况
如需验证您的 Flutter SDK 是否配置正确,以及您是否已安装上述目标平台中的至少一个,请使用 Flutter Doctor 工具:
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale en) [✓] Android toolchain - develop for Android devices [✓] Xcode - develop for iOS and macOS [✓] Chrome - develop for the web [✓] Android Studio [✓] IntelliJ IDEA Ultimate Edition [✓] VS Code [✓] Connected device (4 available) [✓] Network resources • No issues found!
3. 运行起始应用
下载起始应用
使用 git
从 GitHub 上的 flutter/samples 代码库克隆起始应用。
$ git clone https://github.com/flutter/codelabs.git $ cd codelabs/animations/step_01/
或者,您也可以下载 .zip 格式的源代码。
运行应用
如需运行应用,请使用 flutter run
命令并指定目标设备,例如 android
、ios
或 chrome
。如需查看支持的平台的完整列表,请参阅支持的平台页面。
$ flutter run -d android
您还可以使用自己偏好的 IDE 运行和调试应用。如需了解详情,请参阅官方 Flutter 文档。
浏览代码
起始应用是一款多选题知识问答游戏,遵循模型-视图-视图-模型(MVVM)设计模式,由两个界面组成。QuestionScreen
(视图)使用 QuizViewModel
(视图-模型)类向用户提出 QuestionBank
(模型)类中的多项选择题。
- home_screen.dart - 显示一个包含新游戏按钮的界面
- main.dart - 配置
MaterialApp
以使用 Material 3 并显示主屏幕 - model.dart - 定义整个应用中使用的核心类
- question_screen.dart - 显示知识问答游戏的界面
- view_model.dart - 存储知识问答游戏的状态和逻辑,由
QuestionScreen
显示
该应用尚不支持任何动画效果,但当用户按 New Game 按钮时,Flutter 的 Navigator
类会显示默认的视图转换。
4. 使用隐式动画效果
在许多情况下,隐式动画都是不错的选择,因为它们不需要任何特殊配置。在本部分中,您将更新 StatusBar
微件,使其显示动画得分板。如需查找常见的隐式动画效果,请浏览 ImplicitlyAnimatedWidget API 文档。
创建无动画的计分板微件
使用以下代码创建一个新文件 lib/scoreboard.dart
:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
Icon(
Icons.star,
size: 50,
color:
score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
)
],
),
);
}
}
然后,在 StatusBar
微件的子项中添加 Scoreboard
微件,替换之前显示得分和题目总数的 Text
微件。编辑器应会自动在文件顶部添加所需的 import "scoreboard.dart"
。
lib/question_screen.dart
class StatusBar extends StatelessWidget {
final QuizViewModel viewModel;
const StatusBar({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Scoreboard( // NEW
score: viewModel.score, // NEW
totalQuestions: viewModel.totalQuestions, // NEW
),
],
),
),
);
}
}
此 widget 会为每个题目显示一个星形图标。答对一个题目后,另一颗星星会立即亮起,而不会显示任何动画。在后续步骤中,您将通过为分数添加动画效果来通知用户其分数已发生变化。
使用隐式动画效果
创建一个名为 AnimatedStar
的新微件,该微件使用 AnimatedScale
微件在星形变为活动状态时将 scale
金额从 0.5
更改为 1.0
:
lib/scoreboard.dart
import 'package:flutter/material.dart';
class Scoreboard extends StatelessWidget {
final int score;
final int totalQuestions;
const Scoreboard({
super.key,
required this.score,
required this.totalQuestions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < totalQuestions; i++)
AnimatedStar( // NEW
isActive: score > i, // NEW
) // NEW
],
),
);
}
}
class AnimatedStar extends StatelessWidget { // Add from here...
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: Icon(
Icons.star,
size: 50,
color: isActive ? _activatedColor : _deactivatedColor,
),
);
}
} // To here.
现在,当用户正确回答问题时,AnimatedStar
widget 会使用隐式动画更新其大小。此处不会为 Icon
的 color
添加动画,只会为 scale
添加动画,由 AnimatedScale
widget 完成。
使用补间动画在两个值之间进行插值
请注意,在 isActive
字段更改为 true 后,AnimatedStar
widget 的颜色会立即更改。
如需实现动画颜色效果,您可以尝试使用 AnimatedContainer
微件(这是 ImplicitlyAnimatedWidget
的另一个子类),因为它可以自动为其所有属性(包括颜色)添加动画效果。很抱歉,我们的微件需要显示图标,而不是容器。
您还可以尝试使用 AnimatedIcon
,它可在图标形状之间实现过渡效果。但 AnimatedIcons
类中没有星形图标的默认实现。
而是使用另一个名为 TweenAnimationBuilder
的 ImplicitlyAnimatedWidget
子类,该类接受 Tween
作为参数。补间动画是一个类,它接受两个值(begin
和 end
),并计算中间值,以便动画可以显示这些值。在此示例中,我们将使用 ColorTween
,它满足构建动画效果所需的 Tween<Color>
接口。
选择 Icon
widget,然后在 IDE 中使用“Wrap with Builder”(使用构建器封装)快捷操作,将名称更改为 TweenAnimationBuilder
。然后提供时长和 ColorTween
。
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
duration: _duration,
child: TweenAnimationBuilder( // Add from here...
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) { // To here.
return Icon(
Icons.star,
size: 50,
color: value, // Modify from here...
);
}, // To here.
),
);
}
}
现在,热重载应用以查看新动画。
请注意,ColorTween
的 end
值会根据 isActive
参数的值而变化。这是因为,每当 Tween.end
值发生变化时,TweenAnimationBuilder
都会重新运行其动画。在这种情况下,新动画会从当前动画值运行到新的结束值,这样您就可以随时更改颜色(即使在动画运行时),并显示具有正确中间值的流畅动画效果。
应用曲线
这两种动画效果的运行速度都是恒定的,但动画在加速或减速时通常会更具视觉吸引力和信息性。
Curve
会应用缓动函数,该函数用于定义参数随时间的变化率。Flutter 在 Curves
类中附带了一系列预构建的缓动曲线,例如 easeIn
或 easeOut
。
这些图表(可在 Curves
API 文档页面上找到)可帮助您了解曲线的运作方式。曲线会将介于 0.0 到 1.0 之间的输入值(显示在 x 轴上)转换为介于 0.0 到 1.0 之间的输出值(显示在 y 轴上)。这些图表还预览了使用缓动曲线时各种动画效果的样子。
在 AnimatedStar 中创建一个名为 _curve
的新字段,并将其作为参数传递给 AnimatedScale
和 TweenAnimationBuilder
微件。
lib/scoreboard.dart
class AnimatedStar extends StatelessWidget {
final bool isActive;
final Duration _duration = const Duration(milliseconds: 1000);
final Color _deactivatedColor = Colors.grey.shade400;
final Color _activatedColor = Colors.yellow.shade700;
final Curve _curve = Curves.elasticOut; // NEW
AnimatedStar({super.key, required this.isActive});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: isActive ? 1.0 : 0.5,
curve: _curve, // NEW
duration: _duration,
child: TweenAnimationBuilder(
curve: _curve, // NEW
duration: _duration,
tween: ColorTween(
begin: _deactivatedColor,
end: isActive ? _activatedColor : _deactivatedColor,
),
builder: (context, value, child) {
return Icon(
Icons.star,
size: 50,
color: value,
);
},
),
);
}
}
在此示例中,elasticOut
曲线提供了夸张的弹簧效果,从弹簧运动开始,并在结束时平衡。
热重载应用,查看此曲线应用于 AnimatedSize
和 TweenAnimationBuilder
的情况。
使用开发者工具启用慢动画
如需调试任何动画效果,Flutter 开发者工具提供了一种方法来放慢应用中的所有动画,以便您更清楚地查看动画。
如需打开 DevTools,请确保应用在调试模式下运行,然后在 VSCode 的 Debug 工具栏中选择 Widget Inspector,或在 IntelliJ / Android Studio 的 Debug 工具窗口中选择 Open Flutter DevTools 按钮。
Widget 检查器打开后,点击工具栏中的放慢动画按钮。
5. 使用明确的动画效果
与隐式动画一样,显式动画也是预构建的动画效果,但它们接受 Animation
对象作为参数,而不是接受目标值。因此,在动画已由导航转换、AnimatedSwitcher
或 AnimationController
等定义的情况下,这些方法非常有用。
使用显式动画效果
如需开始使用显式动画效果,请使用 AnimatedSwitcher
封装 Card
微件。
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
), // NEW
);
}
}
AnimatedSwitcher
默认使用交叉淡出效果,但您可以使用 transitionBuilder
参数替换此效果。转场效果构建器会提供传递给 AnimatedSwitcher
的子微件和 Animation
对象。这是一个使用显式动画的好机会。
在此 Codelab 中,我们将使用第一个显式动画 SlideTransition
,它接受一个 Animation<Offset>
,用于定义传入和传出 widget 将在其中移动的起始和结束偏移量。
补间有辅助函数 animate()
,用于将任何 Animation
转换为应用了补间的另一个 Animation
。这意味着,Tween<Offset>
可用于将 AnimatedSwitcher
提供的 Animation<double>
转换为 Animation<Offset>
,以提供给 SlideTransition
微件。
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
transitionBuilder: (child, animation) { // Add from here...
final curveAnimation =
CurveTween(curve: Curves.easeInCubic).animate(animation);
final offsetAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
.animate(curveAnimation);
return SlideTransition(position: offsetAnimation, child: child);
}, // To here.
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
请注意,这会使用 Tween.animate
将 Curve
应用于 Animation
,然后将其从范围为 0.0 到 1.0 的 Tween<double>
转换为在 x 轴上从 -0.1 转换为 0.0 的 Tween<Offset>
。
或者,Animation 类有一个 drive()
函数,该函数接受任何 Tween
(或 Animatable
),并将其转换为新的 Animation
。这样可以“串联”补间动画,使生成的代码更简洁:
lib/question_screen.dart
transitionBuilder: (child, animation) {
var offsetAnimation = animation
.drive(CurveTween(curve: Curves.easeInCubic))
.drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
return SlideTransition(position: offsetAnimation, child: child);
},
使用显式动画的另一个优势是,它们可以轻松组合在一起。通过封装 SlideTransition widget,添加另一个显式动画 FadeTransition,该动画使用相同的曲线动画。
lib/question_screen.dart
return AnimatedSwitcher(
transitionBuilder: (child, animation) {
final curveAnimation =
CurveTween(curve: Curves.easeInCubic).animate(animation);
final offsetAnimation =
Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
.animate(curveAnimation);
final fadeInAnimation = curveAnimation; // NEW
return FadeTransition( // NEW
opacity: fadeInAnimation, // NEW
child: SlideTransition(position: offsetAnimation, child: child), // NEW
); // NEW
},
自定义 layoutBuilder
您可能会注意到 AnimationSwitcher 存在一个小问题。当 QuestionCard 切换到新问题时,它会在动画播放期间将新问题放置在可用空间的中心,但当动画停止时,该 widget 会自动贴靠到屏幕顶部。这会导致动画卡顿,因为题目卡片的最终位置与动画运行时的相应位置不一致。
为了解决此问题,AnimatedSwitcher 还提供了一个 layoutBuilder 参数,可用于定义布局。使用此函数配置布局构建器,将卡片对齐到屏幕顶部:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
此代码是 AnimatedSwitcher 类中的 defaultLayoutBuilder 的修改版,但使用 Alignment.topCenter
而非 Alignment.center
。
摘要
- 显式动画是采用 Animation 对象的动画效果(与采用目标值和时长的 ImplicitlyAnimatedWidgets 相对)
- Animation 类表示正在运行的动画,但不定义特定效果。
- 使用 Tween().animate 或 Animation.drive() 将补间和曲线(使用 CurveTween)应用于动画。
- 使用 AnimatedSwitcher 的 layoutBuilder 参数调整其子项的布局方式。
6. 控制动画的状态
到目前为止,框架已自动运行每个动画。隐式动画会自动运行,而显式动画效果需要动画才能正常运行。在本部分中,您将学习如何使用 AnimationController 创建自己的 Animation 对象,以及如何使用 TweenSequence 将 Tween 组合在一起。
使用 AnimationController 运行动画
如需使用 AnimationController 创建动画,您需要按以下步骤操作:
- 创建 StatefulWidget
- 在 State 类中使用 SingleTickerProviderStateMixin 混入容器为 AnimationController 提供计时器
- 在 initState 生命周期方法中初始化 AnimationController,将当前 State 对象提供给
vsync
(TickerProvider) 参数。 - 请确保每当 AnimationController 通知其监听器时,您的 widget 都会重新构建,方法是使用 AnimatedBuilder 或手动调用 listen() 和 setState。
创建一个新文件 flip_effect.dart,然后复制并粘贴以下代码:
lib/flip_effect.dart
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: widget.duration);
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
}
@override
void didUpdateWidget(covariant CardFlipEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.child.key != oldWidget.child.key) {
_handleChildChanged(widget.child, oldWidget.child);
}
}
void _handleChildChanged(Widget newChild, Widget previousChild) {
_previousChild = previousChild;
_animationController.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationController.value * math.pi),
child: _animationController.isAnimating
? _animationController.value < 0.5
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
}
每当框架调用 didUpdateWidget 以通知其微件配置已更改且可能有新的子微件时,此类都会设置 AnimationController 并重新运行动画。
AnimatedBuilder 可确保每当 AnimationController 通知其监听器时,重新构建 widget 树,并且 Transform widget 用于应用 3D 旋转效果,以模拟翻转卡片。
如需使用此 widget,请将每个答案卡片封装在 CardFlipEffect widget 中。请务必向 Card 微件提供 key
:
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect( // NEW
duration: const Duration(milliseconds: 300), // NEW
child: Card.filled( // NEW
key: ValueKey(answers[index]), // NEW
color: color,
elevation: 2,
margin: EdgeInsets.all(8),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => onTapped(index),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
answers.length > index ? answers[index] : '',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.clip,
),
),
),
),
), // NEW
);
}),
);
}
现在,热重载应用,看看答案卡片是否会使用 CardFlipEffect 微件翻转。
您可能会注意到,此类看起来很像显式动画效果。事实上,通常最好直接扩展 AnimatedWidget 类以实现自己的版本。遗憾的是,由于此类需要在其 State 中存储上一个 widget,因此它需要使用 StatefulWidget。如需详细了解如何创建自己的显式动画效果,请参阅 AnimatedWidget 的 API 文档。
使用 TweenSequence 添加延迟
在本部分中,您将向 CardFlipEffect 微件添加延迟,以便每个卡片一次翻转一个。首先,添加一个名为 delayAmount
的新字段。
lib/flip_effect.dart
class CardFlipEffect extends StatefulWidget {
final Widget child;
final Duration duration;
final double delayAmount; // NEW
const CardFlipEffect({
super.key,
required this.child,
required this.duration,
required this.delayAmount, // NEW
});
@override
State<CardFlipEffect> createState() => _CardFlipEffectState();
}
然后,将 delayAmount
添加到 AnswerCards
build 方法中。
lib/question_screen.dart
@override
Widget build(BuildContext context) {
return GridView.count(
shrinkWrap: true,
crossAxisCount: 2,
childAspectRatio: 5 / 2,
children: List.generate(answers.length, (index) {
var color = Theme.of(context).colorScheme.primaryContainer;
if (correctAnswer == index) {
color = Theme.of(context).colorScheme.tertiaryContainer;
}
return CardFlipEffect(
delayAmount: index.toDouble() / 2, // NEW
duration: const Duration(milliseconds: 300),
child: Card.filled(
key: ValueKey(answers[index]),
然后,在 _CardFlipEffectState
中,创建一个使用 TweenSequence
应用延迟的新动画。请注意,此方法不会使用 dart:async
库中的任何实用程序,例如 Future.delayed
。这是因为延迟是动画的一部分,而不是 widget 使用 AnimationController 时明确控制的。这样一来,在 DevTools 中启用慢动画后,动画效果就更容易调试,因为它使用的是相同的 TickerProvider。
如需使用 TweenSequence
,请创建两个 TweenSequenceItem
,其中一个包含一个 ConstantTween
,用于在相对时长内将动画保持在 0 的值,另一个包含一个从 0.0
到 1.0
的常规 Tween
。
lib/flip_effect.dart
class _CardFlipEffectState extends State<CardFlipEffect>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
Widget? _previousChild;
late final Animation<double> _animationWithDelay; // NEW
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this, duration: widget.duration * (widget.delayAmount + 1));
_animationController.addListener(() {
if (_animationController.value == 1) {
_animationController.reset();
}
});
_animationWithDelay = TweenSequence<double>([ // NEW
if (widget.delayAmount > 0) // NEW
TweenSequenceItem( // NEW
tween: ConstantTween<double>(0.0), // NEW
weight: widget.delayAmount, // NEW
), // NEW
TweenSequenceItem( // NEW
tween: Tween(begin: 0.0, end: 1.0), // NEW
weight: 1.0, // NEW
), // NEW
]).animate(_animationController); // NEW
}
最后,在 build 方法中将 AnimationController 的动画替换为新的延迟动画。
lib/flip_effect.dart
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationWithDelay, // Modify this line
builder: (context, child) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateX(_animationWithDelay.value * math.pi), // And this line
child: _animationController.isAnimating
? _animationWithDelay.value < 0.5 // And this one.
? _previousChild
: Transform.flip(flipY: true, child: child)
: child,
);
},
child: widget.child,
);
}
现在,热重载应用,然后观察卡片逐一翻转。如需挑战一下,不妨尝试实验如何更改 Transform
微件提供的 3D 效果的透视图。
7. 使用自定义导航过渡
到目前为止,我们已经了解了如何在单个屏幕上自定义效果,但动画的另一种用法是用于在屏幕之间进行转场。在本部分中,您将学习如何使用内置动画效果和 pub.dev 上官方动画软件包提供的精美预构建动画效果,将动画效果应用于屏幕转换
为导航过渡添加动画效果
PageRouteBuilder
类是一个 Route
,可让您自定义转场动画。您可以替换其 transitionBuilder
回调,该回调会提供两个 Animation 对象,分别表示由导航器运行的传入和传出动画。
如需自定义转场动画,请将 MaterialPageRoute
替换为 PageRouteBuilder
,并自定义用户从 HomeScreen
导航到 QuestionScreen
时的转场动画。使用 FadeTransition(一个明确带动画的 widget)可让新屏幕在旧屏幕上方淡入。
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder( // NEW
pageBuilder: (context, animation, secondaryAnimation) { // NEW
return QuestionScreen(); // NEW
}, // NEW
transitionsBuilder: // NEW
(context, animation, secondaryAnimation, child) { // NEW
return FadeTransition( // NEW
opacity: animation, // NEW
child: child, // NEW
); // NEW
}, // NEW
), // NEW
);
},
child: Text('New Game'),
),
动画软件包提供精美的预构建动画效果,例如 FadeThroughTransition。导入动画软件包,并将 FadeTransition 替换为 FadeThroughTransition 微件:
lib/home_screen.dart
import 'package;animations/animations.dart';
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) {
return const QuestionScreen();
},
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition( // NEW
animation: animation, // NEW
secondaryAnimation: secondaryAnimation, // NEW
child: child, // NEW
); // NEW
},
),
);
},
child: Text('New Game'),
),
自定义预测性返回动画
预测性返回是一项 Android 新功能,可让用户在导航前查看当前路线或应用背后的内容。预览动画由用户在屏幕上向后拖动手指时手指的位置驱动。
当 Flutter 没有路由可在其导航堆栈中弹出时(换言之,当返回会退出应用时),Flutter 会在系统级别启用该功能,以支持系统预测性返回。此动画由系统处理,而非由 Flutter 本身处理。
Flutter 还支持在 Flutter 应用内浏览路线时使用预测性返回。一个名为 PredictiveBackPageTransitionsBuilder
的特殊 PageTransitionsBuilder 会监听系统预测性返回手势,并根据手势的进度驱动其页面转换。
只有 Android U 及更高版本支持预测性返回,但 Flutter 会优雅地回退到原始返回手势行为和 ZoomPageTransitionBuilder。如需了解详情,请参阅我们的博文,其中包含有关如何在您自己的应用中进行设置的部分。
在应用的 ThemeData 配置中,将 PageTransitionsTheme 配置为在 Android 上使用 PredictiveBack,并在其他平台上使用动画软件包中的淡出转场效果:
lib/main.dart
import 'package:animations/animations.dart'; // NEW
import 'package:flutter/material.dart';
import 'home_screen.dart';
void main() {
runApp(MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), // NEW
TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.windows: FadeThroughPageTransitionsBuilder(), // NEW
TargetPlatform.linux: FadeThroughPageTransitionsBuilder(), // NEW
},
),
),
home: HomeScreen(),
);
}
}
现在,您可以将 Navigator.push() 回调更改为 MaterialPageRoute。
lib/home_screen.dart
ElevatedButton(
onPressed: () {
// Show the question screen to start the game
Navigator.push(
context,
MaterialPageRoute(builder: (context) { // NEW
return const QuestionScreen(); // NEW
}), // NEW
);
},
child: Text('New Game'),
),
使用 FadeThroughTransition 更改当前题目
AnimatedSwitcher 微件在其构建器回调中仅提供一个动画。为解决此问题,animations
软件包提供了 PageTransitionSwitcher。
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.question,
super.key,
});
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher( // NEW
layoutBuilder: (entries) { // NEW
return Stack( // NEW
alignment: Alignment.topCenter, // NEW
children: entries, // NEW
); // NEW
}, // NEW
transitionBuilder: (child, animation, secondaryAnimation) { // NEW
return FadeThroughTransition( // NEW
animation: animation, // NEW
secondaryAnimation: secondaryAnimation, // NEW
child: child, // NEW
); // NEW
}, // NEW
duration: const Duration(milliseconds: 300),
child: Card(
key: ValueKey(question),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
),
);
}
}
使用 OpenContainer
animations
软件包中的 OpenContainer 微件提供容器转换动画效果,该效果会展开即可在两个微件之间建立视觉连接。
系统最初会显示 closedBuilder
返回的 widget,并在用户点按容器或调用 openContainer
回调时展开为 openBuilder
返回的 widget。
如需将 openContainer
回调连接到视图模型,请添加一个新的传递,将 viewModel 传递到 QuestionCard Widget,并存储用于显示“Game Over”屏幕的回调:
lib/question_screen.dart
class QuestionScreen extends StatefulWidget {
const QuestionScreen({super.key});
@override
State<QuestionScreen> createState() => _QuestionScreenState();
}
class _QuestionScreenState extends State<QuestionScreen> {
late final QuizViewModel viewModel =
QuizViewModel(onGameOver: _handleGameOver);
VoidCallback? _showGameOverScreen; // NEW
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Scaffold(
appBar: AppBar(
actions: [
TextButton(
onPressed:
viewModel.hasNextQuestion && viewModel.didAnswerQuestion
? () {
viewModel.getNextQuestion();
}
: null,
child: const Text('Next'),
)
],
),
body: Center(
child: Column(
children: [
QuestionCard( // NEW
onChangeOpenContainer: _handleChangeOpenContainer, // NEW
question: viewModel.currentQuestion?.question, // NEW
viewModel: viewModel, // NEW
), // NEW
Spacer(),
AnswerCards(
onTapped: (index) {
viewModel.checkAnswer(index);
},
answers: viewModel.currentQuestion?.possibleAnswers ?? [],
correctAnswer: viewModel.didAnswerQuestion
? viewModel.currentQuestion?.correctAnswer
: null,
),
StatusBar(viewModel: viewModel),
],
),
),
);
},
);
}
void _handleChangeOpenContainer(VoidCallback openContainer) { // NEW
_showGameOverScreen = openContainer; // NEW
} // NEW
void _handleGameOver() { // NEW
if (_showGameOverScreen != null) { // NEW
_showGameOverScreen!(); // NEW
} // NEW
} // NEW
}
添加一个新的微件 GameOverScreen:
lib/question_screen.dart
class GameOverScreen extends StatelessWidget {
final QuizViewModel viewModel;
const GameOverScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Scoreboard(
score: viewModel.score,
totalQuestions: viewModel.totalQuestions,
),
Text(
'You Win!',
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
style: Theme.of(context).textTheme.displaySmall,
),
ElevatedButton(
child: Text('OK'),
onPressed: () {
Navigator.popUntil(context, (route) => route.isFirst);
},
),
],
),
),
);
}
}
在 QuestionCard widget 中,将 Card 替换为动画软件包中的 OpenContainer widget,为 ViewModel 和打开容器回调添加两个新字段:
lib/question_screen.dart
class QuestionCard extends StatelessWidget {
final String? question;
const QuestionCard({
required this.onChangeOpenContainer,
required this.question,
required this.viewModel,
super.key,
});
final ValueChanged<VoidCallback> onChangeOpenContainer;
final QuizViewModel viewModel;
static const _backgroundColor = Color(0xfff2f3fa);
@override
Widget build(BuildContext context) {
return PageTransitionSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation, secondaryAnimation) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: OpenContainer( // NEW
key: ValueKey(question), // NEW
tappable: false, // NEW
closedColor: _backgroundColor, // NEW
closedShape: const RoundedRectangleBorder( // NEW
borderRadius: BorderRadius.all(Radius.circular(12.0)), // NEW
), // NEW
closedElevation: 4, // NEW
closedBuilder: (context, openContainer) { // NEW
onChangeOpenContainer(openContainer); // NEW
return ColoredBox( // NEW
color: _backgroundColor, // NEW
child: Padding( // NEW
padding: const EdgeInsets.all(16.0), // NEW
child: Text(
question ?? '',
style: Theme.of(context).textTheme.displaySmall,
),
),
);
},
openBuilder: (context, closeContainer) { // NEW
return GameOverScreen(viewModel: viewModel); // NEW
}, // NEW
),
);
}
}
8. 恭喜
恭喜,您已成功向 Flutter 应用添加动画效果,并了解了 Flutter 动画系统的核心组件。具体而言,您学习了:
- 如何使用 ImplicitlyAnimatedWidget
- 如何使用 ExplicitlyAnimatedWidget
- 如何将曲线和补间动画应用于动画
- 如何使用预构建的转换 widget,例如 AnimatedSwitcher 或 PageRouteBuilder
- 如何使用
animations
软件包中的精美预构建动画效果,例如 FadeThroughTransition 和 OpenContainer - 如何自定义默认的转换动画,包括在 Android 上添加对预测性返回的支持。
后续操作
查看以下 Codelab:
或者下载动画示例应用,该应用展示了各种动画技术
深入阅读
您可以在 flutter.dev 上找到更多动画资源:
- 动画简介
- “动画”教程(教程)
- 隐式动画(教程)
- 为容器的属性添加动画效果(食谱)
- 淡入淡出 widget(食谱)
- 主打动画
- 为页面路线过渡添加动画效果(食谱)
- 使用物理模拟为微件添加动画(食谱)
- 交错动画
- 动画和动作微件(微件目录)
或者,您也可以参阅 Medium 上的以下文章:
- 动画深入解析
- Flutter 中的自定义隐式动画
- 使用 Flutter 和 Flux / Redux 进行动画管理
- 如何选择适合您的 Flutter 动画 widget?
- 带有内置显式动画的方向动画
- 使用隐式动画的 Flutter 动画基础
- 何时应使用 AnimatedBuilder 或 AnimatedWidget?