Flutter 中的动画

1. 简介

动画是改善应用用户体验、向用户传达重要信息,以及让应用更加精致且使用起来更愉快的绝佳方式。

Flutter 动画框架概览

Flutter 通过在每一帧中重新构建 widget 树的一部分来显示动画效果。它提供了预构建的动画效果和其他 API,可简化动画的创建和组合。

  • 隐式动画是指会自动运行整个动画的预构建动画效果。当动画的目标值发生变化时,它会从当前值运行到目标值,并显示中间的每个值,以便 widget 顺畅地呈现动画效果。隐式动画的示例包括 AnimatedSizeAnimatedScaleAnimatedPositioned
  • 显式动画也是预构建的动画效果,但需要 Animation 对象才能正常运行。例如 SizeTransitionScaleTransitionPositionedTransition
  • Animation 是一个类,表示正在运行或已停止的动画,由表示动画正在运行的目标值的和表示动画在任何给定时间在屏幕上显示的当前值的状态组成。它是 Listenable 的子类,会在动画运行期间状态发生变化时通知其监听器。
  • AnimationController 是一种创建动画并控制其状态的方法。它的方法(例如 forward()reset()stop()repeat())可用于控制动画,而无需定义要显示的动画效果(例如缩放比例、尺寸或位置)。
  • 补间用于在开始值和结束值之间插值值,并且可以表示任何类型,例如 double、OffsetColor
  • 曲线用于调整参数随时间的变化率。在动画运行时,通常会应用缓动曲线,以在动画的开始或结束时加快或减慢变化率。曲线接受介于 0.0 到 1.0 之间的输入值,并返回介于 0.0 到 1.0 之间的输出值。

构建内容

在此 Codelab 中,您将构建一个包含各种动画效果和技巧的多选题知识问答游戏。

3026390ad413769c.gif

您将了解如何...

  • 构建一个会为其大小和颜色添加动画效果的小组件
  • 构建 3D 卡片翻转效果
  • 使用动画软件包中的精美预构建动画效果
  • 添加了对最新版 Android 上提供的预测性返回手势的支持

学习内容

在此 Codelab 中,您将学习:

  • 如何使用隐式动画效果实现效果出色的动画,而无需大量代码。
  • 如何使用显式动画效果,通过预构建的动画微件(例如 AnimatedSwitcherAnimationController)配置自己的效果。
  • 如何使用 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,以便进行调试)。
  • WindowsLinuxmacOS 桌面计算机。您必须在打算部署到的平台上进行开发。因此,如果您要开发 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 命令并指定目标设备,例如 androidioschrome。如需查看支持的平台的完整列表,请参阅支持的平台页面。

$ 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 显示

fbb1e1f7b6c91e21.png

该应用尚不支持任何动画效果,但当用户按 New Game 按钮时,Flutter 的 Navigator 类会显示默认的视图转换。

4. 使用隐式动画效果

在许多情况下,隐式动画都是不错的选择,因为它们不需要任何特殊配置。在本部分中,您将更新 StatusBar 微件,使其显示动画得分板。如需查找常见的隐式动画效果,请浏览 ImplicitlyAnimatedWidget API 文档。

206dd8d9c1fae95.gif

创建无动画的计分板微件

使用以下代码创建一个新文件 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 会使用隐式动画更新其大小。此处不会为 Iconcolor 添加动画,只会为 scale 添加动画,由 AnimatedScale widget 完成。

84aec4776e70b870.gif

使用补间动画在两个值之间进行插值

请注意,在 isActive 字段更改为 true 后,AnimatedStar widget 的颜色会立即更改。

如需实现动画颜色效果,您可以尝试使用 AnimatedContainer 微件(这是 ImplicitlyAnimatedWidget 的另一个子类),因为它可以自动为其所有属性(包括颜色)添加动画效果。很抱歉,我们的微件需要显示图标,而不是容器。

您还可以尝试使用 AnimatedIcon,它可在图标形状之间实现过渡效果。但 AnimatedIcons 类中没有星形图标的默认实现。

而是使用另一个名为 TweenAnimationBuilderImplicitlyAnimatedWidget 子类,该类接受 Tween 作为参数。补间动画是一个类,它接受两个值(beginend),并计算中间值,以便动画可以显示这些值。在此示例中,我们将使用 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.
      ),
    );
  }
}

现在,热重载应用以查看新动画。

8b0911f4af299a60.gif

请注意,ColorTweenend 值会根据 isActive 参数的值而变化。这是因为,每当 Tween.end 值发生变化时,TweenAnimationBuilder 都会重新运行其动画。在这种情况下,新动画会从当前动画值运行到新的结束值,这样您就可以随时更改颜色(即使在动画运行时),并显示具有正确中间值的流畅动画效果。

应用曲线

这两种动画效果的运行速度都是恒定的,但动画在加速或减速时通常会更具视觉吸引力和信息性。

Curve 会应用缓动函数,该函数用于定义参数随时间的变化率。Flutter 在 Curves 类中附带了一系列预构建的缓动曲线,例如 easeIneaseOut

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

这些图表(可在 Curves API 文档页面上找到)可帮助您了解曲线的运作方式。曲线会将介于 0.0 到 1.0 之间的输入值(显示在 x 轴上)转换为介于 0.0 到 1.0 之间的输出值(显示在 y 轴上)。这些图表还预览了使用缓动曲线时各种动画效果的样子。

在 AnimatedStar 中创建一个名为 _curve 的新字段,并将其作为参数传递给 AnimatedScaleTweenAnimationBuilder 微件。

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 曲线提供了夸张的弹簧效果,从弹簧运动开始,并在结束时平衡。

8f84142bff312373.gif

热重载应用,查看此曲线应用于 AnimatedSizeTweenAnimationBuilder 的情况。

206dd8d9c1fae95.gif

使用开发者工具启用慢动画

如需调试任何动画效果,Flutter 开发者工具提供了一种方法来放慢应用中的所有动画,以便您更清楚地查看动画。

如需打开 DevTools,请确保应用在调试模式下运行,然后在 VSCode 的 Debug 工具栏中选择 Widget Inspector,或在 IntelliJ / Android Studio 的 Debug 工具窗口中选择 Open Flutter DevTools 按钮。

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Widget 检查器打开后,点击工具栏中的放慢动画按钮。

adea0a16d01127ad.png

5. 使用明确的动画效果

与隐式动画一样,显式动画也是预构建的动画效果,但它们接受 Animation 对象作为参数,而不是接受目标值。因此,在动画已由导航转换、AnimatedSwitcherAnimationController 等定义的情况下,这些方法非常有用。

使用显式动画效果

如需开始使用显式动画效果,请使用 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.animateCurve 应用于 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 会自动贴靠到屏幕顶部。这会导致动画卡顿,因为题目卡片的最终位置与动画运行时的相应位置不一致。

d77de181bdde58f7.gif

为了解决此问题,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 创建动画,您需要按以下步骤操作:

  1. 创建 StatefulWidget
  2. 在 State 类中使用 SingleTickerProviderStateMixin 混入容器为 AnimationController 提供计时器
  3. 在 initState 生命周期方法中初始化 AnimationController,将当前 State 对象提供给 vsync (TickerProvider) 参数。
  4. 请确保每当 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 微件翻转。

5455def725b866f6.gif

您可能会注意到,此类看起来很像显式动画效果。事实上,通常最好直接扩展 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.01.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 效果的透视图

28b5291de9b3f55f.gif

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'),
),

自定义预测性返回动画

1c0558ffa3b76439.gif

预测性返回是一项 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

77358e5776eb104c.png

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
      ),
    );
  }
}

4120f9395857d218.gif

8. 恭喜

恭喜,您已成功向 Flutter 应用添加动画效果,并了解了 Flutter 动画系统的核心组件。具体而言,您学习了:

  • 如何使用 ImplicitlyAnimatedWidget
  • 如何使用 ExplicitlyAnimatedWidget
  • 如何将曲线和补间动画应用于动画
  • 如何使用预构建的转换 widget,例如 AnimatedSwitcher 或 PageRouteBuilder
  • 如何使用 animations 软件包中的精美预构建动画效果,例如 FadeThroughTransition 和 OpenContainer
  • 如何自定义默认的转换动画,包括在 Android 上添加对预测性返回的支持。

3026390ad413769c.gif

后续操作

查看以下 Codelab:

或者下载动画示例应用,该应用展示了各种动画技术

深入阅读

您可以在 flutter.dev 上找到更多动画资源:

或者,您也可以参阅 Medium 上的以下文章:

参考文档