Building Beautiful Transitions with Material Motion for Flutter

1. Introduction

Material Design is a system for building bold and beautiful digital products. By uniting style, branding, interaction, and motion under a consistent set of principles and components, product teams can realize their greatest design potential.

logo_components_color_2x_web_96dp.png

Material Components (MDC) help developers implement Material Design. Created by a team of engineers and UX designers at Google, MDC features dozens of beautiful and functional UI components and is available for Android, iOS, web and Flutter.material.io/develop

What is Material's motion system for Flutter?

The Material motion system for Flutter is a set of transition patterns within the animations package that can help users understand and navigate an app, as described in the Material Design guidelines.

The four main Material transition patterns are as follows:

  • Container Transform: transitions between UI elements that include a container; creates a visible connection between two distinct UI elements by seamlessly transforming one element into another.

11807bdf36c66657.gif

  • Shared Axis: transitions between UI elements that have a spatial or navigational relationship; uses a shared transformation on the x, y, or z axis to reinforce the relationship between elements.

71218f390abae07e.gif

  • Fade Through: transitions between UI elements that do not have a strong relationship to each other; uses a sequential fade out and fade in, with a scale of the incoming element.

385ba37b8da68969.gif

  • Fade: used for UI elements that enter or exit within the bounds of the screen.

cfc40fd6e27753b6.gif

The animations package offers transition widgets for these patterns, built on top of both the Flutter animations library (flutter/animation.dart) and the Flutter material library (flutter/material.dart):

In this codelab you will be using the Material transitions built on top of the Flutter framework and Material library, meaning you will be dealing with widgets. :)

What you'll build

This codelab will guide you through building some transitions into an example Flutter email app called Reply, using Dart, to demonstrate how you can use transitions from the animations package to customize the look and feel of your app.

The starter code for the Reply app will be provided, and you will incorporate the following Material transitions into the app, which can be seen in the completed codelab's GIF below:

  • Container Transform transition from email list to email detail page
  • Container Transform transition from FAB to compose email page
  • Shared Z-Axis transition from search icon to search view page
  • Fade Through transition between mailbox pages
  • Fade Through transition between compose and reply FAB
  • Fade Through transition between disappearing mailbox title
  • Fade Through transition between bottom app bar actions

b26fe84fed12d17d.gif

What you'll need

  • Basic knowledge of Flutter development and Dart
  • A code editor
  • An Android/iOS emulator or device
  • The sample code (see next step)

How would you rate your level of experience building Flutter apps?

Novice Intermediate Proficient

What would you like to learn from this codelab?

I'm new to the topic, and I want a good overview. I know something about this topic, but I want a refresher. I'm looking for example code to use in my project. I'm looking for an explanation of something specific.

2. Set up your Flutter development environment

You need two pieces of software to complete this lab—the Flutter SDK and an editor.

You can run the codelab using any of these devices:

  • A physical Android or iOS device connected to your computer and set to Developer mode.
  • The iOS simulator (requires installing Xcode tools).
  • The Android Emulator (requires setup in Android Studio).
  • A browser (Chrome is required for debugging).
  • As a Windows, Linux, or macOS desktop application. You must develop on the platform where you plan to deploy. So, if you want to develop a Windows desktop app, you must develop on Windows to access the appropriate build chain. There are operating system-specific requirements that are covered in detail on docs.flutter.dev/desktop.

3. Download the codelab starter app

Option 1: Clone the starter codelab app from GitHub

To clone this codelab from GitHub, run the following commands:

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

Option 2: Download the starter codelab app zip file

The starter app is in the material-components-flutter-motion-codelab-starter directory.

Verify project dependencies

The project depends on the animations package. In the pubspec.yaml, notice the dependencies section includes the following:

animations: ^2.0.0

Open the project and run the app

  1. Open the project in your editor of choice.
  2. Follow the instructions to "Run the app" in Get Started: Test drive for your chosen editor.

Success! The starter code for Reply's homepage should run on your device/emulator. You should see the Inbox containing a list of emails.

Reply home page

Optional: Slow down device animations

Since this codelab involves quick, yet polished transitions, it can be useful to slow down the device's animations to observe some finer details of the transitions as you are implementing. This can be accomplished through an in-app setting, accessible through a tap on the settings icon when the bottom drawer is open. Do not worry, this method of slowing down device animations will not affect animations on the device outside of the Reply app.

d23a7bfacffac509.gif

Optional: Dark Mode

If the bright theme of Reply is hurting your eyes, look no further. There is an included in-app setting that allows you to change the app theme to dark mode, to better suit your eyes. This setting is accessible by tapping the settings icon when the bottom drawer is open.

87618d8418eee19e.gif

4. Get familiar with the sample app code

Let's look at the code. We've provided an app that uses the animations package to transition between different screens in the application.

  • HomePage: displays the selected mailbox
  • InboxPage: displays a list of emails
  • MailPreviewCard: displays a preview of an email
  • MailViewPage: displays a single, full email
  • ComposePage: allows for the composition of a new email
  • SearchPage: displays a search view

router.dart

First, to understand how the app's root navigation is setup, open up router.dart in the lib directory:

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

This is our root navigator, and it handles our app's screens that consume the entire canvas, such as the HomePage and the SearchPage. It listens to our app's state to check if we have set the route to the ReplySearchPath. If we have, then it rebuilds our navigator with the SearchPage at the top of the stack. Notice that our screens are wrapped in a CustomTransitionPage with no transitions defined. This shows you one way to navigate between screens without any custom transition.

home.dart

We set our route to ReplySearchPath in our app's state by doing the following inside of _BottomAppBarActionItems in home.dart:

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

In our onPressed parameter, we access our RouterProvider and set its routePath to ReplySearchPath. Our RouterProvider keeps track of our root navigators state.

mail_view_router.dart

Now, let's see how our app's inner navigation is set up, open up mail_view_router.dart in the lib directory. You'll see a navigator similar to the one above:

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

This is our inner navigator. It handles our app's inner screens that consume only the body of the canvas, such as the InboxPage. The InboxPage displays a list of emails depending on what the current mailbox is in our app's state. The navigator is rebuilt with the correct InboxPage on top of the stack, whenever there is a change in the currentlySelectedInbox property of our app's state.

home.dart

We set our current mailbox in our app's state by doing the following inside of _HomePageState in home.dart:

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(() {});
}

In our _onDestinationSelected function, we access our EmailStore and set its currentlySelectedInbox to the selected destination. Our EmailStore keeps track of our inner navigators state.

home.dart

Lastly, to see an example of a navigation routing being used, open up home.dart in the lib directory. Locate the _ReplyFabState class, inside the InkWell widget's onTap property, which should look like this:

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

This shows how you can navigate to the email compose page, without any custom transition. During this codelab, you will dive into Reply's code to set up Material transitions that work in tandem with the various navigation actions throughout the app.

Now that you're familiar with the starter code, let's implement our first transition.

5. Add Container Transform transition from email list to email detail page

To begin, you will add a transition when clicking on an email. For this navigation change, the container transform pattern is well suited, as it's designed for transitions between UI elements that include a container. This pattern creates a visible connection between two UI elements.

Before adding any code, try running the Reply app and clicking on an email. It should do a simple jump-cut, which means the screen is replaced with no transition:

Before

48b00600f73c7778.gif

Begin by adding an import for the animations package at the top of mail_card_preview.dart as shown in the following snippet:

mail_card_preview.dart

import 'package:animations/animations.dart';

Now that you have an import for the animations package, we can begin adding beautiful transitions to your app. Let's start by creating a StatelessWidget class that will house our OpenContainer widget.

In mail_card_preview.dart, add the following code snippet after the class definition of the 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,
       );
     },
   );
 }
}

Now let's put our new wrapper to use. Inside of the MailPreviewCard class definition we will wrap the Material widget from our build() function with our new _OpenContainerWrapper:

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

Our _OpenContainerWrapper has an InkWell widget and the color properties of the OpenContainer define the color of the container it encloses. Therefore, we can remove the Material and Inkwell widgets. The resulting code looks as follows:

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

At this stage, you should have a fully working container transform. Clicking on an email expands the list item into a details screen while receding the list of emails. Pressing back collapses the email details screen back into a list item while scaling up in the list of emails.

After

663e8594319bdee3.gif

6. Add Container Transform transition from FAB to compose email page

Let's continue with container transform and add a transition from the Floating Action Button to ComposePage expanding the FAB to a new email to be written by the user. First, re-run the app and click on the FAB to see that there is no transition when launching the email compose screen.

Before

4aa2befdc5170c60.gif

The way we configure this transition will be very similar to how we did it in the last step, since we are using the same widget class, the OpenContainer.

In home.dart, let's import the package:animations/animations.dart at the top of the file, and modify the _ReplyFabState build() method. Let's wrap the returned Material widget with an OpenContainer widget:

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

In addition to the parameters used to configure our previous OpenContainer widget, onClosed is now also being set. onClosed is a ClosedCallback that is called when the OpenContainer route has been popped or has returned to the closed state. The return value of that transaction is passed to this function as an argument. We use this Callback to notify our app's provider that we have left the ComposePage route, so that it can notify all listeners.

Similar to what we did for our last step, we will remove the Material widget from our widget since the OpenContainer widget handles the color of the widget returned by the closedBuilder with closedColor. We will also remove our Navigator.push() call inside of our InkWell widget's onTap, and replace it with the openContainer() Callback given by the OpenContainer widget's closedBuilder, since now the OpenContainer widget is handling its own routing.

The resulting code is as follows:

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

Now to clean up some old code. Since our OpenContainer widget now handles notifying our app's provider that we are no longer on the ComposePage through the onClosed ClosedCallback, we can remove our previous implementation in 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);

That's it for this step! You should have a transition from the FAB to compose screen that looks like the following:

After

5c7ad1b4b40f9f0c.gif

7. Add Shared Z-Axis transition from search icon to search view page

In this step, we'll add a transition from the search icon to the full screen search view. Since there is no persistent container involved in this navigation change, we can use a Shared Z-Axis transition to reinforce the spatial relationship between the two screens and indicate moving one level upward in the app's hierarchy.

Before adding additional code, try running the app and tapping the search icon at the bottom right corner of the screen. This should bring up the search view screen with no transition.

Before

df7683a8ad7b920e.gif

To begin, let's go to our router.dart file. After our ReplySearchPath class definition add the following snippet:

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

Now, let's utilize our new SharedAxisTransitionPageWrapper to achieve the transition we want. Inside of our ReplyRouterDelegate class definition, under the pages property, let's wrap our search screen with a SharedAxisTransitionPageWrapper instead of a 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(),
     ),
 ],
);

Now try re-running the app.

81b3ea098926931.gif

Things are starting to look great! When you click on the search icon in the bottom app bar, a shared axis transition scales the search page into view. However, notice how the home page does not scale out and instead stays static as the search page scales in over it. Additionally, when pressing the back button, the home page does not scale into view, instead it stays static as the search page scales out of view. So we're not done yet.

Let's fix both issues by also wrapping the HomePage with our SharedAxisTransitionWrapper instead of a 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 SharedAxisTransitionPageWrapper(
     transitionKey: ValueKey('home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: SearchPage(),
     ),
 ],
);

That's it! Now try re-running the app and tapping on the search icon. The home and search view screens should simultaneously fade and scale along the Z-axis in depth, creating a seamless effect between the two screens.

After

462d890086a3d18a.gif

8. Add Fade Through transition between mailbox pages

In this step, we'll add a transition between different mailboxes. Since we don't want to emphasize a spatial or hierarchical relationship, we'll use a fade through to perform a simple "swap" between lists of emails.

Before adding any additional code, try running the app, tapping on the Reply logo in the Bottom App Bar, and switching mailboxes. The list of emails should change with no transition.

Before

89033988ce26b92e.gif

To begin, let's go to our mail_view_router.dart file. After our MailViewRouterDelegate class definition add the following snippet:

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

Similar to our last step, let's utilize our new FadeThroughTransitionPageWrapper to achieve the transition we want. Inside of our MailViewRouterDelegate class definition, under the pages property, instead of wrapping our mailbox screen with a CustomTransitionPage, use FadeThroughTransitionPageWrapper instead:

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

Re-run the app. When you open the bottom navigation drawer and change mailboxes, the current list of emails should fade and scale out while the new list fades and scales in. Nice!

After

8186940082b630d.gif

9. Add Fade Through transition between compose and reply FAB

In this step, we'll add a transition between different FAB icons. Since we don't want to emphasize a spatial or hierarchical relationship, we'll use a fade through to perform a simple "swap" between the icons in the FAB.

Before adding any additional code, try running the app, tapping on an email and opening up the email view. The FAB icon should change without a transition.

Before

d8e3afa0447cfc20.gif

We will be working in home.dart for the remainder of the codelab, so don't worry about adding the import for the animations package since we already did for home.dart back in step 2.

The way we configure the next couple of transitions will be very similar, since they will all make use of a reusable class, _FadeThroughTransitionSwitcher.

In home.dart let's add the following snippet under _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,
   );
 }
}

Now, in our _ReplyFabState, look for the fabSwitcher widget. The fabSwitcher returns a different icon based on whether it's in email view or not. Let's wrap it with our _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,
             ),
     );
...

We give our _FadeThroughTransitionSwitcher a transparent fillColor, so there is no background between elements when transitioning. We also create a UniqueKey and assign it to one of the icons.

Now, at this step, you should have a fully animated contextual FAB. Going into an email view causes the old FAB icon to fade and scale out while the new one fades and scales in.

After

c55bacd9a144ec69.gif

10. Add Fade Through transition between disappearing mailbox title

In this step, we'll add a fade through transition, to fade through the mailbox title between a visible and invisible state when on an email view. Since we don't want to emphasize a spatial or hierarchical relationship, we'll use a fade through to perform a simple "swap" between the Text widget that encompasses the mailbox title, and an empty SizedBox.

Before adding any additional code, try running the app, tapping on an email and opening up the email view. The mailbox title should disappear without a transition.

Before

59eb57a6c71725c0.gif

The rest of this codelab will be quick since we already did most of the work in our _FadeThroughTransitionSwitcher in our last step.

Now, let's go to our _AnimatedBottomAppBar class in home.dart to add our transition. We will be reusing _FadeThroughTransitionSwitcher from our last step, and wrapping our onMailView conditional, that either returns an empty SizedBox, or a mailbox title that fades in sync with the bottom drawer:

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
                   .bodyMedium!
                   .copyWith(
                     color: ReplyColors.white50,
                   ),
             );
           },
         ),
       ),
),

That's it, we're done with this step!

Re-run the app. When you open up an email and are taken to the email view, the mailbox title in the bottom app bar should fade and scale out. Awesome!

After

3f1a3db01a481124.gif

11. Add Fade Through transition between bottom app bar actions

In this step, we'll add a fade through transition, to fade through the bottom app bar actions based on the applications context. Since we don't want to emphasize a spatial or hierarchical relationship, we'll use a fade through to perform a simple "swap" between the bottom app bar actions when the app is on the HomePage, when the bottom drawer is visible, and when we are on the email view.

Before adding any additional code, try running the app, tapping on an email and opening up the email view. You can also try tapping the Reply logo. The bottom app bar actions should change without a transition.

Before

5f662eac19fce3ed.gif

Similar to the last step, we will be utilizing our _FadeThroughTransitionSwitcher again. To achieve the desired transition go to our _BottomAppBarActionItems class definition and wrap the return widget of our build() function with a _FadeThroughTransitionSwitcher:

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

Now let's try it! When you open up an email and are taken to the email view, the old bottom app bar actions should fade and scale out while the new actions fade and scale in. Well done!

After

cff0fa2afa1c5a7f.gif

12. Congratulations!

Using fewer than 100 lines of Dart code, the animations package has helped you create beautiful transitions in an existing app that conforms to the Material Design guidelines, and also looks and behaves consistently across all devices.

d5637de49eb64d8a.gif

Next steps

For more information on the Material motion system, be sure to check out the guidelines and full developer documentation, and try adding some Material transitions to your app!

Thanks for trying Material motion. We hope you enjoyed this codelab!

I was able to complete this codelab with a reasonable amount of time and effort

Strongly agree Agree Neutral Disagree Strongly disagree

I would like to continue using the Material motion system in the future

Strongly agree Agree Neutral Disagree Strongly disagree

For more demos on how to use widgets provided by the Material Flutter library, as well the Flutter framework make sure to visit the Flutter Gallery.

46ba920f17198998.png

6ae8ae284bf4f9fa.png