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

1. 简介

Material Design 是一个系统,用于构建醒目、美观的数字产品。产品团队可以根据一套统一的原则和组件将样式、品牌、交互性和运动效果结合起来,从而发挥最大的设计潜力。

logo_components_color_2x_web_96dp.png

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

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

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

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

  • 容器转换:用于包含容器的界面元素之间的过渡;通过将一个元素无缝转换为另一个元素,在两个不同的界面元素之间创造可视化的连接。

11807bdf36c66657.gif

  • 共享轴:用于具有空间或导航关系的界面元素之间的过渡;让元素在转换时共用 x 轴、y 轴或 z 轴,用以强调元素间的关系。

71218f390abae07e.gif

  • 淡出后淡入:用于彼此之间没有密切关系的界面元素之间的过渡;使用依序淡出和淡入的效果,并会对转入的元素进行缩放。

385ba37b8da68969.gif

  • 淡出:用于进入或退出屏幕画面范围的界面元素。

cfc40fd6e27753b6.gif

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

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

构建内容

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

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

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

b26fe84fed12d17d.gif

所需条件

  • 了解有关 Flutter 开发和 Dart 的基础知识
  • 代码编辑器
  • Android/iOS 模拟器或设备
  • 示例代码(参见下一步)

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

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

您想通过此 Codelab 学习什么?

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

2. 设置您的 Flutter 开发环境

您需要使用两款软件才能完成此 Codelab:Flutter SDK一款编辑器

您可以使用以下任一设备运行此 Codelab:

  • 一台连接到计算机并设置为开发者模式的实体 AndroidiOS 设备。
  • iOS 模拟器(需要安装 Xcode 工具)。
  • Android 模拟器(需要在 Android Studio 中设置)。
  • 浏览器(调试需要 Chrome)。
  • 作为 WindowsLinuxmacOS 桌面应用使用。您必须在打算实施部署的平台上进行开发。因此,如果您要开发 Windows 桌面应用,则必须在 Windows 上进行开发,才能访问相应的构建链。如需详细了解针对各种操作系统的具体要求,请访问 docs.flutter.dev/desktop

3. 下载 Codelab 起始应用

选项 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 目录中。

验证项目依赖项

该项目依赖于动画软件包。请注意,在 pubspec.yaml 中,dependencies 部分包含以下内容:

animations: ^2.0.0

打开项目并运行应用

  1. 在您选择的编辑器中打开项目。
  2. 按照所选编辑器的《使用入门:试驾》中的说明“运行应用”。

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

Reply 首页

可选:将设备动画播放速度放慢

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

d23a7bfacffac509.gif

可选:深色模式

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

87618d8418eee19e.gif

4. 熟悉示例应用代码

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

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

router.dart

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

class ReplyRouterDelegate extends RouterDelegate<ReplyRoutePath>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin<ReplyRoutePath> {
 ReplyRouterDelegate({required this.replyState})
     : 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) {
   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 = const ReplySearchPath();
   },
 ),
);

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

mail_view_router.dart

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

class MailViewRouterDelegate extends RouterDelegate<void>
   with ChangeNotifier, PopNavigatorRouterDelegateMixin {
 MailViewRouterDelegate({required 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 转场。

现在,您已经熟悉了起始代码,接下来,我们要实现第一个过渡。

5. 添加从电子邮件列表到电子邮件详情页面的容器转换过渡

首先,您将添加在点击电子邮件时显示的过渡。对于这种导航方式变化,容器转换模式非常适用,因为该模式是专为包含容器的界面元素之间的过渡而设计的。这个模式可在两个界面元素之间创建可视化连接。

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

之前

48b00600f73c7778.gif

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

 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() 函数的 Material 微件:

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(
...

我们的 _OpenContainerWrapper 具有 InkWell 微件,OpenContainer 的颜色属性定义了其包含的容器的颜色。因此,我们可以移除 Material 和 Inkwell 微件。生成的代码如下所示:

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,
   },
   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.scaffoldBackgroundColor,
     iconColor: currentEmailStarred
         ? colorScheme.onSecondary
         : colorScheme.onBackground,
     alignment: Alignment.centerRight,
     padding: const EdgeInsetsDirectional.only(end: 20),
   ),
   child: mailPreview,
 ),
);

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

之后

663e8594319bdee3.gif

6. 添加从 FAB 到电子邮件撰写页面的容器转换过渡

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

之前

4aa2befdc5170c60.gif

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

home.dart 中,导入文件顶部的 package:animations/animations.dart,然后修改 _ReplyFabState build() 方法。我们用 OpenContainer 微件封装返回的 Material 微件:

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,
     ...

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

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

生成的代码如下所示:

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>(true);

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

之后

5c7ad1b4b40f9f0c.gif

7. 添加从搜索图标到搜索视图页面的共享 Z 轴过渡

在这一步,我们将添加从搜索图标到全屏搜索视图的过渡。由于这项导航方式变化不涉及任何永久性容器,因此,我们可以使用共享 Z 轴过渡来加强两个屏幕之间的空间关系,并指明要在应用的层次结构中上移一级。

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

之前

df7683a8ad7b920e.gif

首先,转到 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})
     : 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 属性下,我们使用 SharedAxisTransitionPageWrapper(而非 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(
     transitionKey: ValueKey('Home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('Search'),
       screen: SearchPage(),
     ),
 ],
);

现在尝试再次运行该应用。

81b3ea098926931.gif

效果开始变好了!当您点击底部应用栏的搜索图标时,共享轴转场会将搜索页面放大为视图。不过请注意,首页不会随着扩展,而是在搜索页面放大时,保持静态不变。此外,按下返回按钮时,首页不会展开进入视图,而是在搜索页面退出视图时保持静态。因此,我们的任务还没有完成。

接下来,我们还可以使用 SharedAxisTransitionWrapper(而不是 CustomTransitionPage)封装 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: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: SearchPage(),
     ),
 ],
);

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

之后

462d890086a3d18a.gif

8. 在邮箱页面之间添加淡出后淡入过渡

在这一步,我们将在不同邮箱之间添加过渡。由于我们并不想突出空间或层级关系,因此,我们将使用淡出后淡入效果在电子邮件列表之间进行简单的“转换”。

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

之前

89033988ce26b92e.gif

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

mail_view_router.dart

// TODO: Add Fade through transition between mailbox pages (Motion)
class FadeThroughTransitionPageWrapper extends Page {
 const FadeThroughTransitionPageWrapper({
   required this.mailbox,
   required this.transitionKey,
 })  : 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 封装邮箱屏幕,而要改用 FadeThroughTransitionPageWrapper

mail_view_router.dart

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

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

之后

8186940082b630d.gif

9. 在撰写和回复 FAB 之间添加“淡出后淡入”转场

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

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

之前

d8e3afa0447cfc20.gif

我们将在 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,
 });

 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 会根据是否在电子邮件视图中返回不同的图标。请使用 _FadeThroughTransitionSwitcher 封装它:

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,因此在元素转场期间不会有背景颜色。 此外,我们还创建了一个 UniqueKey,并将其分配给其中一个图标。

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

之后

c55bacd9a144ec69.gif

10. 在即将从页面中消失的邮箱标题之间添加“淡出后淡入”转场

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

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

之前

59eb57a6c71725c0.gif

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

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

home.dart

...
const _ReplyLogo(),
const SizedBox(width: 10),
// TODO: Add Fade through transition between disappearing mailbox title (Motion)
_FadeThroughTransitionSwitcher(
 fillColor: Colors.transparent,
 child: onMailView
     ? const SizedBox(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,
                   ),
             );
           },
         ),
       ),
),

这一步到此就完成了!

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

之后

3f1a3db01a481124.gif

11. 在底部应用栏操作之间添加“淡出后淡入”转场

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

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

之前

5f662eac19fce3ed.gif

与上一步类似,我们将再次使用 _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
...

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

之后

cff0fa2afa1c5a7f.gif

12. 恭喜!

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

d5637de49eb64d8a.gif

后续步骤

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

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

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

非常赞同 赞同 一般 不赞同 非常不赞同

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

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

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

46ba920f17198998.png

6ae8ae284bf4f9fa.png