利用适用于 Flutter 的 Material 动效系统构建精美转场

Material Design 是一个用于构建醒目、美观的数字产品的系统。采用一套统一的原则和组件将风格、品牌、交互和动效结合起来,产品团队得以释放其极大的设计潜能。

logo_components_color_2x_web_96dp.png

Material 组件 (MDC) 可帮助开发者实现 Material Design。MDC 由 Google 的工程师和用户体验设计人员倾力打造,提供数十种精美实用的界面组件,可用于 Android、iOS、网页和 Flutter。如需了解详情,请访问 material.io/develop

适用于 Flutter 的 Material 动效系统是什么?

Material Design 准则中所述,适用于 Flutter 的 Material 动效系统是动画软件包内的一套转场模式,有助于用户了解应用和在应用中导航。

四种主要的 Material 转场模式如下:

  • 容器转换:包含一个容器的界面元素之间的转场;通过将一个元素无缝转换为另一个元素,在两个不同的界面元素之间创建一个可见连接。

b9fd67c205755d55.gif

  • 共享轴:具有空间或导航关系的界面元素之间的转场;在 x、y 或 z 轴上使用共享转换来强化元素之间的关系。

76622de33a19179.gif

  • 淡出后淡入:彼此之间没有密切关系的界面元素之间的转场;对一定范围的传入元素,使用顺序淡出和淡入。

18a525c038443492.gif

  • 淡变:用于进入或退出屏幕边界的界面元素。

cd10a0580a159644.gif

动画软件包提供适用于上述模式的转场微件,这些微件基于 Flutter 动画库 (flutter/animation.dart) 和 Flutter 材料库 (flutter/material.dart) 构建而成:

在此 Codelab 中,您将使用基于 Flutter 框架和 Material 库构建的 Material 转场,这意味着您将用到微件。:)

构建内容

此 Codelab 将指导您使用 Dart 在一个名为 Reply 的示例 Flutter 电子邮件应用中构建一些转场,演示如何使用动画软件包中的转场来自定义应用的外观和风格。

我们将提供 Reply 应用的初始代码,您可以将以下 Material 转场整合到应用中,如下面已完成的此 Codelab GIF 所示:

  • 从电子邮件收件人列表到电子邮件详情页面的容器转换转场
  • 从 FAB 到撰写电子邮件页面的容器转换转场
  • 从搜索图标到搜索视图页面的共享 Z 轴转场
  • 邮箱页面之间的淡出后淡入转场
  • 撰写和回复 FAB 之间的淡出后淡入转场
  • 即将从页面中消失的邮箱标题的淡出后淡入转场
  • 底部应用栏操作之间的淡出后淡入转场

5f7b8860db2c70e2.gif

所需条件

  • 了解有关 Flutter 开发和 Dart 的基础知识
  • Android Studio(如果尚未安装,请在此处下载)
  • Android 模拟器或设备(可通过 Android Studio 获取)
  • 示例代码(参见下一步)

您如何评价自己在 Flutter 应用构建方面的经验水平?

新手水平 中等水平 熟练水平

您想通过此 Codelab 学习什么?

我不熟悉这个主题,想深入了解一下。我对这个主题有所了解,但我想重温一下。我在寻找示例代码以用到我的项目中。我在寻找有关特定内容的说明。

前期准备

如需开始使用 Flutter 开发移动应用,您需要执行以下操作:

  1. 下载并安装 Flutter SDK。
  2. 使用 Flutter SDK 更新您的 PATH。
  3. 安装带有 Flutter 和 Dart 插件的 Android Studio 或您喜欢使用的编辑器。
  4. 安装 Android 模拟器、iOS 模拟器(需要装有 Xcode 的 Mac)或使用实体设备。

如需详细了解 Flutter 安装说明,请参阅开始使用:安装。如需设置编辑器,请参阅开始使用:设置编辑器。安装 Android 模拟器时,您可以根据需要选择默认选项,例如带有最新系统映像的 Pixel 3 手机。建议启用虚拟机加速功能,但这并非强制要求。完成上述 4 个步骤后,您可以返回此 Codelab。您只需要为一个平台(Android 或 iOS)安装 Flutter 即可完成此 Codelab。

确保 Flutter SDK 的版本符合要求

在继续学习此 Codelab 之前,请确保 SDK 版本符合要求。如果之前已安装 Flutter SDK,请使用 flutter upgrade 确保此 SDK 是最新版本。

 flutter upgrade

运行 flutter upgrade 将自动运行 flutter doctor.。如果此次是全新的 Flutter 安装,则无需升级,手动运行 flutter doctor 即可。它将报告您是否需要安装任何依赖项才能完成此安装操作。您可以忽略与您的情况无关的勾选项(例如,如果您不打算开发 iOS 应用,就可以忽略 Xcode)。

 flutter doctor

常见问题解答

启动 Android Studio

当您打开 Android Studio 时,它将显示一个标题为“Welcome to Android Studio”的窗口。不过,如果这是您第一次启动 Android Studio,请使用默认值完成 Android Studio 设置向导中的步骤。此步骤可能需要几分钟时间才能完成下载和安装必要的文件,因此您可以使其在后台保持运行,同时继续下一个部分的操作。

选项 1:从 GitHub 克隆入门版 Codelab 应用

如需从 GitHub 克隆此 Codelab,请运行以下命令:

git clone https://github.com/material-components/material-components-flutter-motion-codelab.git
cd material-components-flutter-motion-codelab

选项 2下载入门版 Codelab 应用 zip 文件

下载入门版应用

入门版应用位于 material-components-flutter-motion-codelab-starter 目录中。

在 Android Studio 中加载起始代码

  1. 完成设置向导且系统显示 Welcome to Android Studio 窗口后,点击 Open an existing Android Studio project

e3f200327a67a53.png

  1. 导航到您安装了示例代码的目录,然后选择示例目录以打开项目。
  2. 等待 Android Studio 构建和同步项目,如 Android Studio 窗口底部的 activity 指示器所示。
  3. 此时,Android Studio 可能会引发一些构建错误,因为缺少 Android SDK 或构建工具(如下所示)。按照 Android Studio 中的说明进行安装/更新,并同步您的项目。如果问题仍未解决,请按照指南中的说明使用 SDK 管理器更新工具。

6e026ae171f5b1eb.png

  1. 如果出现提示,请执行以下操作:
  • 安装所有的平台和插件更新或 FlutterRunConfigurationType。
  • 如果未配置 Dart 或 Flutter SDK,请设置 Flutter 插件的 Flutter SDK 路径
  • 配置 Android 框架。
  • 点击“Get dependencies”或“Run ‘flutter packages get'”。

然后重启 Android Studio。

53b7195f1c1deedb.png

be5ce477ba09225e.png 24810642cf859588.png

验证项目依赖项

项目需要动画软件包依赖项。您下载的示例代码应该已经列出了此依赖项,但我们还是一起检查一下配置,以确保万无一失。

转到 app 模块的 pubspec.yaml 文件,并确保 dependencies 部分包含对动画软件包的依赖项:

animations: ^1.1.2

运行入门版应用

  1. 确保设备选择左侧的构建配置为 app
  2. 按绿色的“Run/Play”按钮来构建并运行应用。

a34cba7fab0a2af9.png

  1. 在编辑器屏幕顶部的 Flutter Device Selection 下拉菜单中,如果您的可用设备中列有某一设备,请跳至第 8 步。否则,请点击 Create New Virtual Device
  2. Select Hardware 屏幕中,选择一部手机设备(如 Pixel 3),然后点击 Next
  3. System Image 屏幕中,选择最新的 Android 版本,最好是最高的 API 级别。如果您尚未安装,请点击随即显示的 Download 链接,然后完成下载。
  4. 点击 Next
  5. Android Virtual Device (AVD) 屏幕上,保持设置原样不变,然后点击 Finish
  6. 选择一个设备(例如,从“Flutter Device Selection”下拉菜单选择“iPhone SE”或“Android SDK built for <版本>”。
  7. Play 图标 (b8c998094aa23ac2.png)。
  8. Android Studio 会构建并部署应用,然后在目标设备上自动打开该应用。

大功告成!Reply 首页的起始代码应该已在模拟器中运行。您应该会看到包含电子邮件列表的收件箱。

Android

iOS

可选:放慢设备动画速度

由于此 Codelab 涉及快速但精细的转场,因此放慢设备的动画速度有助于在实现时观察转场的一些更精细的细节。您可以通过一项应用内设置来实现此操作。当底部的抽屉式导航栏处于打开状态时,只需点按设置图标即可进行设置。别担心,这种放慢设备动画速度的方法不会影响 Reply 应用以外的设备上的动画。

Android

iOS

可选:深色模式

如果 Reply 的浅色主题令您的眼睛感到不适,您可以进行调整。通过一项应用内设置,您可以将应用主题更改为深色模式,以获得更好的视觉感受。当底部的抽屉式导航栏处于打开状态时,只需点按设置图标即可进行设置。

Android

iOS

我们来看一下代码。我们提供了一款应用,该应用使用动画软件包在应用的不同屏幕之间进行转场。

  • HomePage:显示选定的邮箱
  • InboxPage:显示电子邮件列表
  • MailPreviewCard:显示电子邮件预览
  • MailViewPage:显示单个完整的电子邮件
  • ComposePage:允许撰写新电子邮件
  • SearchPage:显示搜索视图

router.dart

首先,如需了解如何设置应用的根导航,请在 lib 目录中打开 router.dart

class ReplyRouterDelegate extends RouterDelegate<ReplyRoutePath>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin<ReplyRoutePath> {
 ReplyRouterDelegate({@required this.replyState})
     : assert(replyState != null),
       navigatorKey = GlobalObjectKey<NavigatorState>(replyState) {
   replyState.addListener(() {
     notifyListeners();
   });
 }

 @override
 final GlobalKey<NavigatorState> navigatorKey;

 RouterProvider replyState;

 @override
 void dispose() {
   replyState.removeListener(notifyListeners);
   super.dispose();
 }

 @override
 ReplyRoutePath get currentConfiguration => replyState.routePath;

 @override
 Widget build(BuildContext context) {
   return MultiProvider(
     providers: [
       ChangeNotifierProvider<RouterProvider>.value(value: replyState),
     ],
     child: Selector<RouterProvider, ReplyRoutePath>(
       selector: (context, routerProvider) => routerProvider.routePath,
       builder: (context, routePath, child) {
         return Navigator(
           key: navigatorKey,
           onPopPage: _handlePopPage,
            pages: [
              // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
              const CustomTransitionPage(
                transitionKey: ValueKey('Home'),
                screen: HomePage(),
              ),
              if (routePath is ReplySearchPath)
                const CustomTransitionPage(
                  transitionKey: ValueKey('Search'),
                  screen: SearchPage(),
                ),
            ],
         );
       },
     ),
   );
 }

 bool _handlePopPage(Route<dynamic> route, dynamic result) {
   // _handlePopPage should not be called on the home page because the
   // PopNavigatorRouterDelegateMixin will bubble up the pop to the
   // SystemNavigator if there is only one route in the navigator.
   assert(route.willHandlePopInternally ||
       replyState.routePath is ReplySearchPath);

   final bool didPop = route.didPop(result);
   if (didPop) replyState.routePath = const ReplyHomePath();
   return didPop;
 }

 @override
 Future<void> setNewRoutePath(ReplyRoutePath configuration) {
   assert(configuration != null);
   replyState.routePath = configuration;
   return SynchronousFuture<void>(null);
 }
}

这是我们的根导航器,它会处理使用整个画布的应用屏幕,如 HomePageSearchPage。它会侦听应用的状态,检查我们是否已将路由设置为 ReplySearchPath。如果是,它将使用堆栈顶部的 SearchPage 重新构建导航器。请注意,我们的屏幕封装在一个 CustomTransitionPage 中,并且未定义转场。此处为您提供了一种在不同屏幕间导航的方式,无需任何自定义转场。

home.dart

我们在 home.dart 中的 _BottomAppBarActionItems 内执行以下操作,在应用状态中将路由设置为 ReplySearchPath

Align(
   alignment: AlignmentDirectional.bottomEnd,
   child: IconButton(
     icon: const Icon(Icons.search),
     color: ReplyColors.white50,
     onPressed: () {
       Provider.of<RouterProvider>(
         context,
         listen: false,
       ).routePath = ReplySearchPath();
     },
   ),
 );

onPressed 参数中,访问 RouterProvider,并将其 routePath 设置为 ReplySearchPath。我们的 RouterProvider 会跟踪根导航器的状态。

mail_view_router.dart

现在,我们来看一下应用的内部导航方式,在 lib 目录中打开 mail_view_router.dart。您将看到与上面类似的导航器:

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({this.drawerController});

 final AnimationController drawerController;

 @override
 Widget build(BuildContext context) {
   bool _handlePopPage(Route<dynamic> route, dynamic result) {
     return false;
   }

   return Selector<EmailStore, String>(
     selector: (context, emailStore) => emailStore.currentlySelectedInbox,
     builder: (context, currentlySelectedInbox, child) {
       return Navigator(
         key: navigatorKey,
         onPopPage: _handlePopPage,
         pages: [
           // TODO: Add Fade through transition between mailbox pages (Motion)
            CustomTransitionPage(
              transitionKey: ValueKey(currentlySelectedInbox),
              screen: InboxPage(
                destination: currentlySelectedInbox,
              ),
            )
          ],
       );
     },
   );
 }
 ...
}

这是我们的内部导航器。它会处理仅使用画布主体的应用的内部屏幕,例如 InboxPageInboxPage 会根据当前邮箱在应用中的状态显示电子邮件列表。只要应用状态的 currentlySelectedInbox 属性发生变化,系统就会自动使用堆栈顶部正确的 InboxPage 重新构建导航器。

home.dart

我们在 home.dart 中的 _HomePageState 内执行以下操作,以设置当前邮箱在应用中的状态:

void _onDestinationSelected(String destination) {
 var emailStore = Provider.of<EmailStore>(
   context,
   listen: false,
 );

 if (emailStore.onMailView) {
   emailStore.currentlySelectedEmailId = -1;
 }

 if (emailStore.currentlySelectedInbox != destination) {
   emailStore.currentlySelectedInbox = destination;
 }

 setState(() {});
}

_onDestinationSelected 函数中,访问 EmailStore 并将其 currentlySelectedInbox 设置为选定的目的地。我们的 EmailStore 会跟踪内部导航器状态。

home.dart

最后,如需查看正在使用的导航路由示例,请在 lib 目录中打开 home.dart。在 InkWell 微件的 onTap 属性中查找 _ReplyFabState 类,它应如下所示:

onTap: () {
 Provider.of<EmailStore>(
   context,
   listen: false,
 ).onCompose = true;

 Navigator.of(context).push(
   PageRouteBuilder(
     pageBuilder: (BuildContext context,
         Animation<double> animation,
         Animation<double> secondaryAnimation,
     ) {
       return const ComposePage();
     },
   ),
 );
}

此处显示的是一种转到电子邮件撰写页面,而无需执行任何自定义转场的方式。在此 Codelab 中,您将深入了解 Reply 的代码,设置在整个应用中配合不同导航操作的 Material 转场。

现在,您已经熟悉了起始代码,接下来就让我们开始实现首个转场。

首先,您需要在点击电子邮件时添加转场。对于此导航变化,容器转换模式非常合适,因为它专为包含一个容器的界面元素之间的转场而设计。这个模式可在两个界面元素之间创建可视化连接。

在添加任何代码之前,请先尝试运行 Reply 应用,然后点击一封电子邮件。屏幕应会进行简单的跳转,即屏幕无需转场即可被替换。

之前

Android

iOS

首先在 mail_card_preview.dart 顶部添加对动画软件包的导入,如以下代码段所示:

mail_card_preview.dart

import 'package:animations/animations.dart';

现在您已经导入了动画软件包,我们可以开始向您的应用添加精美的转场效果。首先,让我们创建一个存储 OpenContainer 微件的 StatelessWidget 类。

mail_card_preview.dart 中,在 MailPreviewCard 的类定义之后添加以下代码段:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
class _OpenContainerWrapper extends StatelessWidget {
 const _OpenContainerWrapper({
   @required this.id,
   @required this.email,
   @required this.closedChild,
 })  : assert(id != null),
       assert(email != null),
       assert(closedChild != null);

 final int id;
 final Email email;
 final Widget closedChild;

 @override
 Widget build(BuildContext context) {
   final theme = Theme.of(context);

   return OpenContainer(
     openBuilder: (context, closedContainer) {
       return MailViewPage(id: id, email: email);
     },
     openColor: theme.cardColor,
     closedShape: const RoundedRectangleBorder(
       borderRadius: BorderRadius.all(Radius.circular(0)),
     ),
     closedElevation: 0,
     closedColor: theme.cardColor,
     closedBuilder: (context, openContainer) {
       return InkWell(
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).currentlySelectedEmailId = id;
           openContainer();
         },
         child: closedChild,
       );
     },
   );
 }
}

现在,让我们来使用新的封装容器。在 MailPreviewCard 类定义中,我们将使用新的 _OpenContainerWrapper 封装来自 build() 函数的 return 微件:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
 return _OpenContainerWrapper(
   id: id,
   email: email,
   closedChild: Material(
     color: theme.cardColor,
     child: InkWell(
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).currentlySelectedEmailId = id;

         mobileMailNavKey.currentState.push(
           PageRouteBuilder(
             pageBuilder: (BuildContext context, Animation<double> animation,
                 Animation<double> secondaryAnimation) {
               return MailViewPage(id: id, email: email);
             },
           ),
         );
       },
       child: Dismissible(
         key: ObjectKey(email),
         dismissThresholds: const {
           DismissDirection.startToEnd: 0.8,
           DismissDirection.endToStart: 0.4,
         },
         onDismissed: (direction) {
           switch (direction) {
             case DismissDirection.endToStart:
               if (onStarredInbox) {
                 onStar();
               }
               break;
             case DismissDirection.startToEnd:
               onDelete();
               break;
             default:
           }
         },
         background: _DismissibleContainer(
           icon: 'twotone_delete',
           backgroundColor: colorScheme.primary,
           iconColor: ReplyColors.blue50,
           alignment: Alignment.centerLeft,
           padding: const EdgeInsetsDirectional.only(start: 20),
         ),
         confirmDismiss: (direction) async {
           if (direction == DismissDirection.endToStart) {
             if (onStarredInbox) {
               return true;
             }
             onStar();
             return false;
           } else {
             return true;
           }
         },
         secondaryBackground: _DismissibleContainer(
           icon: 'twotone_star',
           backgroundColor: currentEmailStarred
               ? colorScheme.secondary
               : Theme.of(context).scaffoldBackgroundColor,
           iconColor: currentEmailStarred
               ? colorScheme.onSecondary
               : colorScheme.onBackground,
           alignment: Alignment.centerRight,
           padding: const EdgeInsetsDirectional.only(end: 20),
         ),
         child: mailPreview,
       ),
     ),
   ),
 );
}

请务必从微件中移除 InkWell,因为其逻辑现在位于 _OpenContainerWrapper 类中。我们还可以移除 Material 微件,因为 OpenContainer 的颜色属性定义了它所包含容器的颜色:

mail_card_preview.dart

// TODO: Add Container Transform transition from email list to email detail page (Motion)
return _OpenContainerWrapper(
 id: id,
 email: email,
 closedChild: Dismissible(
   key: ObjectKey(email),
   dismissThresholds: const {
     DismissDirection.startToEnd: 0.8,
     DismissDirection.endToStart: 0.4,
   }.......

在此阶段,您应该已经实现了完全可正常运行的容器转换。点击电子邮件可以将列表项展开为详细信息屏幕,同时电子邮件列表会消失。按返回图标可将电子邮件详细信息屏幕收起为列表项,同时缩回到电子邮件列表中。

之后

Android

iOS

让我们继续进行容器转换,并添加从悬浮操作按钮 (FAB) 到 ComposePage 的转场,从而按 FAB 可使其展开为用户撰写新电子邮件的屏幕。首先,重新运行应用,然后点击 FAB,以确保启动电子邮件撰写屏幕时,系统不会发生任何转场。

之前

Android

iOS

配置此转场的方式与上一步中非常相似,因为我们使用的是相同的微件类 OpenContainer

home.dart 中,将以下代码段添加到 _ReplyFabState 类定义中,确保将 package:animations/animations.dart 导入文件的顶部。我们在此处使用 OpenContainer 微件封装 _ReplyFabState 类定义 build() 函数的返回微件:

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Material(
     color: theme.colorScheme.secondary,
     shape: circleFabBorder,
     child: Tooltip(
       message: tooltip,
       child: InkWell(
         customBorder: circleFabBorder,
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).onCompose = true;

           Navigator.of(context).push(
             PageRouteBuilder(
               pageBuilder: (BuildContext context,
                   Animation<double> animation,
                   Animation<double> secondaryAnimation,
               ) {
                 return const ComposePage();
               },
             ),
           );
         },
         child: SizedBox(
           height: _mobileFabDimension,
           width: _mobileFabDimension,
           child: Center(
             child: fabSwitcher,
           ),
         ),
       ),
     ),
   );
 },
);

除了用于配置我们之前的 OpenContainer 微件的参数之外,现在还需设置 onClosedonClosed 是当 OpenContainer 路由已弹出或返回到关闭状态时调用的 ClosedCallback。该事务的返回值会以参数的形式传递到此函数中。我们会使用此 Callback 通知应用提供程序我们已离开 ComposePage 路径,以便其通知所有监听器。

与我们在上一步中所执行的操作类似,我们会从微件中移除 Material 微件,因为 OpenContainer 微件会处理 closedBuilder 使用 closedColor 返回的微件的颜色。我们还将移除 InkWell 微件的 onTap 中的 Navigator.push() 调用,并将其替换为 OpenContainer 微件的 closedBuilder 指定的 openContainer() Callback,因为现在由 OpenContainer 微件处理自己的路由。

_ReplyFabState 类定义的 home.dart 中:

home.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
return OpenContainer(
 openBuilder: (context, closedContainer) {
   return const ComposePage();
 },
 openColor: theme.cardColor,
 onClosed: (success) {
   Provider.of<EmailStore>(
     context,
     listen: false,
   ).onCompose = false;
 },
 closedShape: circleFabBorder,
 closedColor: theme.colorScheme.secondary,
 closedElevation: 6,
 closedBuilder: (context, openContainer) {
   return Tooltip(
     message: tooltip,
     child: InkWell(
       customBorder: circleFabBorder,
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).onCompose = true;
         openContainer();
       },
       child: SizedBox(
         height: _mobileFabDimension,
         width: _mobileFabDimension,
         child: Center(
           child: fabSwitcher,
         ),
       ),
     ),
   );
 },
);

现在来清理一些旧代码。由于我们的 OpenContainer 微件现在通过 onClosed ClosedCallback 通知应用提供程序我们不再位于 ComposePage,因此我们可以移除在 mail_view_router.dart 中的先前实现:

mail_view_router.dart

// TODO: Add Container Transform from FAB to compose email page (Motion)
emailStore.onCompose = false; /// delete this line
return SynchronousFuture<bool>(false);

这一步到此即告完成!您应该实现了从 FAB 页面到撰写屏幕的转场,如下所示:

之后

Android

iOS

在这一步中,我们将添加从搜索图标切换到全屏搜索视图的转场。由于此导航更改没有持续存在的容器,所以可以使用共享 Z 轴转场来加强两个屏幕之间的空间关系,并表示在应用的层次结构中向上移动一级。

在添加其他代码之前,请尝试运行该应用,然后点按屏幕右下角的搜索图标。执行此操作后,系统会显示搜索视图屏幕,无任何转场。

之前

Android

iOS

首先,转到 router.dart 文件。在 ReplySearchPath 类定义之后,添加以下代码段:

router.dart

// TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
class SharedAxisTransitionPageWrapper extends Page {
  const SharedAxisTransitionPageWrapper(
      {@required this.screen, @required this.transitionKey})
      : assert(screen != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget screen;
  final ValueKey transitionKey;

  @override
  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
        settings: this,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return SharedAxisTransition(
            fillColor: Theme.of(context).cardColor,
            animation: animation,
            secondaryAnimation: secondaryAnimation,
            transitionType: SharedAxisTransitionType.scaled,
            child: child,
          );
        },
        pageBuilder: (context, animation, secondaryAnimation) {
          return screen;
        });
  }
}

现在,我们来利用新的 SharedAxisTransitionPageWrapper 实现所需的转场。我们将微件屏幕封装在封装容器中,这样就能为导航器返回一个页面支持路由,其中包含所需的转场。在 ReplyRouterDelegate 类定义中的 pages 属性下,请使用新的封装容器,而不要使用 CustomTransitionPage 封装搜索屏幕:

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const CustomTransitionPage(
     pageBuilder: (context, animation, secondaryAnimation) {
       return const HomePage();
     },
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

现在尝试重新运行应用。

Android

iOS

一切看起来很棒!当您点击底部应用栏的搜索图标时,共享轴转场会将搜索页面放大为视图。不过请注意,首页不会随着扩展,而是在搜索页面放大时,保持静态不变。此外,按下返回按钮时,首页不会展开进入视图,而是在搜索页面退出视图时保持静态。所以我们的工作到此还未完成。

如需修复首页的转场,只需在 router.dart 中用 SharedAxisTransitionWrapper 封装 HomePage 即可:

router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Shared Z-Axis transition from search icon to search view page (Motion)
   const SharedAxisTransitionPageWrapper(
     transitionKey: ValueKey('home'),
     screen: const HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

大功告成!现在,请尝试重新运行应用,然后点按搜索图标。首页和搜索视图屏幕应同时在 Z 轴上深入淡变和缩放,从而在两个屏幕之间形成无缝转场效果。

之后

Android

iOS

在这一步中,我们将在不同邮箱之间添加转场。由于我们不想突出空间或层级关系,因此,我们采用淡出后淡入的方式在电子邮件列表之间执行简单的“交换”。

在添加其他代码之前,请尝试运行该应用,点按底部应用栏中的 Reply 徽标并切换邮箱。电子邮件列表会发生变化,但无任何转场。

之前

Android

iOS

首先,转到 mail_view_router.dart 文件。在 MailViewRouterDelegate 类定义之后,添加以下代码段:

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
  FadeThroughTransitionPageWrapper({
    @required this.mailbox,
    @required this.transitionKey,
  })  : assert(mailbox != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget mailbox;
  final ValueKey transitionKey;

  @override
  Route createRoute(BuildContext context) {
    return PageRouteBuilder(
        settings: this,
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(
            fillColor: Theme.of(context).scaffoldBackgroundColor,
            animation: animation,
            secondaryAnimation: secondaryAnimation,
            child: child,
          );
        },
        pageBuilder: (context, animation, secondaryAnimation) {
          return mailbox;
        });
  }
}

与上一步类似,利用新的 FadeThroughTransitionPageWrapper 实现所需的转场。我们将邮箱屏幕封装在封装容器中,这样就能为导航器返回一个页面支持路由,其中包含所需的淡出后淡入转场。在 MailViewRouterDelegate 类定义中的 pages 属性下,请使用新的封装容器,而不要使用 CustomTransitionPage 封装邮箱屏幕:

mail_view_router.dart

return Navigator(
 key: navigatorKey,
 onPopPage: _handlePopPage,
 pages: [
   // TODO: Add Fade through transition between different mailbox pages (Motion)
   FadeThroughTransitionPageWrapper(
     mailbox: InboxPage(destination: currentlySelectedInbox),
     transitionKey: ValueKey(currentlySelectedInbox),
   ),
 ],
);

重新运行应用。打开底部抽屉式导航栏并更换邮箱,当前的电子邮件列表会淡出和缩小,而新的列表会淡入和放大。很好!

之后

Android

iOS

在此步骤中,我们将在不同的 FAB 图标之间添加转场。由于我们不想突出空间或层级关系,因此,我们将采用淡出后淡入的方式,在 FAB 中的图标之间执行简单的“交换”。

在添加任何额外代码之前,请尝试运行该应用,点按电子邮件并打开电子邮件视图。FAB 图标会发生变化,但无任何转场。

之前

Android

iOS

我们将在 home.dart 中完成此 Codelab 的剩余部分,因此不必添加动画软件包的导入,因为我们已经在第 2 步中完成了 home.dart 的导入操作。

我们接下来几个转场的配置方式相似,因为它们全部都是可重复使用的类 _FadeThroughTransitionSwitcher

home.dart 中,我们在 _ReplyFabState 下添加以下代码段:

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
class _FadeThroughTransitionSwitcher extends StatelessWidget {
 const _FadeThroughTransitionSwitcher({
   @required this.fillColor,
   @required this.child,
 })  : assert(fillColor != null),
       assert(child != null);

 final Widget child;
 final Color fillColor;

 @override
 Widget build(BuildContext context) {
   return PageTransitionSwitcher(
     transitionBuilder: (child, animation, secondaryAnimation) {
       return FadeThroughTransition(
         fillColor: fillColor,
         child: child,
         animation: animation,
         secondaryAnimation: secondaryAnimation,
       );
     },
     child: child,
   );
 }
}

现在,在我们的 _ReplyFabState 中查找 fabSwitcher 微件。fabSwitcher 微件允许 FAB 根据上下文切换。fabSwitcher 会检查我们是否在电子邮件视图中,如果是,它将为 FAB 分配一个不同的图标。

home.dart

// TODO: Add Fade through transition between compose and reply FAB (Motion)
static final fabKey = UniqueKey();
static const double _mobileFabDimension = 56;

@override
Widget build(BuildContext context) {
 final theme = Theme.of(context);
 final circleFabBorder = const CircleBorder();

 return Selector<EmailStore, bool>(
   selector: (context, emailStore) => emailStore.onMailView,
   builder: (context, onMailView, child) {
      // TODO: Add Fade through transition between compose and reply FAB (Motion)
     final fabSwitcher = _FadeThroughTransitionSwitcher(
       fillColor: Colors.transparent,
       child: onMailView
           ? Icon(
               Icons.reply_all,
               key: fabKey,
               color: Colors.black,
             )
           : const Icon(
               Icons.create,
               color: Colors.black,
             ),
     );

我们为 _FadeThroughTransitionSwitcher 提供了一个透明的 fillColor,因此在元素转场期间不会有背景颜色。

现在,在这一步中,您应该已经实现了一个完全动画化并关联上下文的 FAB。进入电子邮件视图会导致旧的 FAB 图标淡出并缩小,而新的图标淡入和放大。

之后

Android

iOS

在这一步中,我们将增添淡出后淡入效果,让电子邮件视图上的电子邮件标题在可见和不可见状态之间淡入淡出。由于我们不希望强调空间或层级之间的关系,因此,我们使用淡出后淡入的方式在包含邮箱标题的 Text 微件和空 SizedBox 之间进行简单的“交换”。

在添加任何额外代码之前,请尝试运行该应用,点按电子邮件并打开电子邮件视图。邮箱标题应该会消失,但无任何转场。

之前

Android

iOS

此 Codelab 的其余部分进度将会非常快,因为我们在上一步的 _FadeThroughTransitionSwitcher 中完成了大部分工作。

现在,让我们转到 home.dart 中的 _AnimatedBottomAppBar 类来添加转场。我们将重用上一步中的 _FadeThroughTransitionSwitcher,并封装 onMailView 条件,该操作将返回空的 SizedBox 或与底部抽屉式导航栏同步淡入的邮箱标题:

home.dart

const SizedBox(width: 8),
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(height: 0, width: 48)
     : FadeTransition(
         opacity: fadeOut,
         child: Selector<EmailStore, String>(
           selector: (context, emailStore) =>
               emailStore.currentlySelectedInbox,
           builder: (
             context,
             currentlySelectedInbox,
             child,
           ) {
             return Text(
               currentlySelectedInbox,
               style: Theme.of(context)
                   .textTheme
                   .bodyText1
                   .copyWith(
                     color: ReplyColors.white50,
                   ),
             );
           },
         ),
       ),
),

这一步到此就完成了!

重新运行应用。当您打开电子邮件并进入电子邮件视图时,底部应用栏中的邮箱标题应能淡出并缩小。太棒了!

之后

Android

iOS

在这一步中,我们将增添淡出后淡入转场,使底部应用栏操作可根据应用的上下文淡出后淡入。由于我们不想强调空间或层级关系,因此,我们将使用淡出后淡入转场,以在以下情况下在底部应用栏操作之间执行简单的“交换”:当应用处于首页上、当底部抽屉式导航栏可见时,以及当我们处于电子邮件视图上时。

在添加任何额外代码之前,请尝试运行该应用,点按电子邮件并打开电子邮件视图。您也可以点按 Reply 徽标。底部应用栏操作应该会发生变化,但无任何转场。

之前

Android

iOS

与上一步类似,我们将再次使用 _FadeThroughTransitionSwitcher。如需实现所需的转场,请转至我们的 _BottomAppBarActionItems 类定义,并使用 _FadeThroughTransitionSwitcher 封装 build() 函数的返回微件:

home.dart

// TODO: Add Fade through transition between bottom app bar actions (Motion)
return _FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: drawerVisible
     ? Align(
         key: UniqueKey(),
         alignment: AlignmentDirectional.bottomEnd,
         child: IconButton(
           icon: const Icon(Icons.settings),
           color: ReplyColors.white50,
           onPressed: () async {
             drawerController.reverse();
             showModalBottomSheet(
               context: context,
               shape: RoundedRectangleBorder(
                 borderRadius: modalBorder,
               ),
               builder: (context) => const SettingsBottomSheet(),
             );
           },
         ),
       )
     : onMailView
         ? Row(
             mainAxisSize: MainAxisSize.max,
             mainAxisAlignment: MainAxisAlignment.end,
             children: [
               IconButton(
                 icon: ImageIcon(
                   const AssetImage(
                     '$_iconAssetLocation/twotone_star.png',
                     package: _assetsPackage,
                   ),
                   color: starIconColor,
                 ),
                 onPressed: () {
                   model.starEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );
                   if (model.currentlySelectedInbox == 'Starred') {
                     mobileMailNavKey.currentState.pop();
                     model.currentlySelectedEmailId = -1;
                   }
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const ImageIcon(
                   AssetImage(
                     '$_iconAssetLocation/twotone_delete.png',
                     package: _assetsPackage,
                   ),
                 ),
                 onPressed: () {
                   model.deleteEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );

                   mobileMailNavKey.currentState.pop();
                   model.currentlySelectedEmailId = -1;
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const Icon(Icons.more_vert),
                 onPressed: () {},
                 color: ReplyColors.white50,
               ),
             ],
           )
         : Align(
             alignment: AlignmentDirectional.bottomEnd,
             child: IconButton(
               icon: const Icon(Icons.search),
               color: ReplyColors.white50,
               onPressed: () {
                 Provider.of<RouterProvider>(
                   context,
                   listen: false,
                 ).routePath = ReplySearchPath();
               },
             ),
           ),
);

现在,让我们尝试一下!当您打开电子邮件并转到电子邮件视图时,旧的底部应用栏操作应淡出和缩小,而新的操作会淡入和放大。干得好!

之后

Android

iOS

动画软件包虽然使用了不到 100 行 Dart 代码,但能协助您在现有应用内按照 Material Design 准则构建精美的转场效果,并能在所有设备上实现一致的视觉效果和行为。

Android

iOS

后续步骤

如需详细了解 Material 动效系统,请参阅规范和完整的开发者文档,然后尝试向您的应用中添加一些 Material 转场!

感谢您试用 Material 动效。希望您喜欢此 Codelab!

我能够用合理的时间和精力完成此 Codelab

非常赞同 赞同 中立 不赞同 非常不赞同

我希望日后继续使用 Material 动效系统

非常赞同 赞同 中立 不赞同 非常不赞同

有关如何使用 Material Flutter 库提供的微件以及 Flutter 框架的更多演示,请访问 Flutter Gallery

52f7119a30bb8f5c.png

dd11628e4c0f3fd3.png