Cómo compilar transiciones atractivas con el sistema de movimiento de Material para Flutter

1. Introducción

Material Design es un sistema para crear productos digitales atractivos y llamativos. Mediante la unión de estilo, desarrollo de la marca, interacción y movimiento en un conjunto coherente de principios y componentes, los equipos de productos pueden alcanzar su máximo potencial de diseño.

logo_components_color_2x_web_96dp.png

Los componentes de Material (MDC) ayudan a los desarrolladores a implementar Material Design. Los MDC, creados por un equipo de ingenieros y diseñadores de UX en Google, cuentan con decenas de componentes de IU atractivos y funcionales, y están disponibles para Android, iOS, la Web y Flutter.material.io/develop.

¿Qué es el sistema de movimiento de Material para Flutter?

El sistema de movimiento de Material para Flutter es un conjunto de patrones de transición dentro del paquete de animaciones que ayuda a los usuarios a comprender y navegar por una app, como se describe en los lineamientos de Material Design.

Estos son los cuatro patrones de transición principales de Material:

  • Transformación de contenedores: genera una transición entre elementos de la IU que incluyen un contenedor; crea una conexión visible entre dos elementos de la IU diferentes mediante una transformación fluida de un elemento en otro.

11807bdf36c66657.gif

  • Eje compartido: genera una transición entre elementos de la IU que tienen una relación espacial o de navegación; utiliza una transformación compartida en los ejes X, Y o Z para reforzar la relación entre elementos.

71218f390abae07e.gif

  • Atenuación rápida: genera una transición entre elementos de la IU que no tienen una relación estrecha entre sí; utiliza una atenuación secuencial de entrada y salida, con la escala de un elemento nuevo.

385ba37b8da68969.gif

  • Atenuación: se utiliza para los elementos de la IU que entran a los límites de la pantalla o salen de estos.

cfc40fd6e27753b6.gif

El paquete de animaciones ofrece widgets de transición para estos patrones, que se compilaron sobre la biblioteca de animaciones de Flutter (flutter/animation.dart) y la biblioteca de Material de Flutter (flutter/material.dart):

En este codelab, usarás las transiciones de Material que se compilaron sobre el framework de Flutter y la biblioteca de Material, lo que implica que trabajarás con widgets. :)

Qué compilarás

En este codelab, usarás Dart para compilar algunas transiciones en una app de ejemplo de correo electrónico de Flutter que se llama Reply. Te guiaremos para que comprendas cómo usar las transiciones del paquete de animaciones con el objeto de personalizar el aspecto de tu app.

Se te brindará el código de inicio para la app de Reply, e incorporarás en ella las transiciones de Material que se pueden observar en el siguiente GIF del codelab completo:

  • Transición de transformación de contenedores desde la lista de direcciones de correo electrónico hasta la página de detalles del correo electrónico
  • Transición de transformación de contenedores desde el BAF hasta la página para redactar correos electrónicos
  • Transición de eje Z compartido desde el ícono de búsqueda hasta la página de la vista de búsqueda
  • Transición de fundido entre las páginas de los buzones
  • Transición de fundido entre el BAF para redactar y para responder
  • Transición de fundido entre el título del buzón que desaparece
  • Transición de atenuación entre las acciones de la barra inferior de la aplicación

b26fe84fed12d17d.gif

Requisitos

  • Conocimientos básicos sobre el desarrollo de Flutter y Dart
  • Un editor de código
  • Un emulador o dispositivo con Android o iOS
  • El código de muestra (consulta el siguiente paso)

¿Cómo calificarías tu nivel de experiencia con la compilación de apps de Flutter?

Principiante Intermedio Avanzado

¿Qué te gustaría aprender en este codelab?

Desconozco el tema y me gustaría obtener una buena descripción general. Tengo algunos conocimientos sobre este tema, pero me gustaría repasarlos. Estoy buscando código de ejemplo para usar en mi proyecto. Estoy buscando una explicación sobre un tema específico.

2. Configura tu entorno de desarrollo de Flutter

Para completar este lab, necesitas dos programas de software: el SDK de Flutter y un editor.

Puedes ejecutar el codelab con cualquiera de estos dispositivos o modalidades:

  • Un dispositivo físico Android o iOS conectado a tu computadora y configurado en el Modo de desarrollador
  • El simulador de iOS (requiere instalar las herramientas de Xcode)
  • Android Emulator (requiere configuración en Android Studio)
  • Un navegador (se requiere Chrome para la depuración)
  • Como una aplicación para computadoras que ejecuten Windows, Linux o macOS (debes desarrollarla en la plataforma donde tengas pensado realizar la implementación; por lo tanto, si quieres desarrollar una app de escritorio para Windows, debes desarrollarla en ese SO a fin de obtener acceso a la cadena de compilación correcta; encuentra detalles sobre los requisitos específicos del sistema operativo en docs.flutter.dev/desktop).

3. Descarga la app de partida del codelab

Opción 1: Clona la app de partida del codelab desde GitHub

Para clonar este codelab desde GitHub, ejecuta los siguientes comandos:

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

Opción 2: Descarga el archivo ZIP de la app de inicio del codelab

La app de partida se encuentra en el directorio material-components-flutter-motion-codelab-starter.

Verifica las dependencias del proyecto

El proyecto depende del paquete de animaciones. En pubspec.yaml, observa que la sección dependencies incluye lo siguiente:

animations: ^2.0.0

Abre el proyecto y ejecuta la app

  1. Abre el proyecto en el editor que prefieras.
  2. Sigue las instrucciones para “ejecutar la app” en Get Started: Test drive en el editor que elegiste.

Listo. El código de partida para la página principal de Reply se debe estar ejecutando en tu emulador o dispositivo. Deberías ver la carpeta Recibidos con una lista de correos electrónicos.

Página principal de Reply

Disminuye las animaciones del dispositivo (opcional)

Como este codelab abarca transiciones rápidas y refinadas, puede resultar útil disminuir las animaciones del dispositivo para observar algunos de los detalles más sutiles de las transiciones durante la implementación. Esto se puede establecer mediante una opción de configuración en la app, a la que se puede acceder con el ícono de configuración cuando abres el panel lateral inferior. No te preocupes, este método para disminuir las animaciones del dispositivo no afectará las animaciones en el dispositivo fuera de la app de Reply.

d23a7bfacffac509.gif

Modo oscuro (opcional)

Si el tema brillante de Reply te causa molestias en los ojos, tenemos una solución. En la app, se incluye una configuración que te permite cambiar el tema al modo oscuro para que se adapte mejor a la sensibilidad de los ojos. A fin de acceder a este parámetro de configuración, presiona el ícono de configuración cuando abres el panel lateral inferior.

87618d8418eee19e.gif

4. Familiarízate con el código de la app de ejemplo

Echemos un vistazo al código. Brindamos una app que usa el paquete de animaciones para hacer la transición entre diferentes pantallas de la aplicación.

  • HomePage: muestra el buzón seleccionado.
  • InboxPage: muestra una lista de correos electrónicos.
  • MailPreviewCard: muestra la vista previa de un correo electrónico.
  • MailViewPage: muestra un solo correo electrónico completo.
  • ComposePage: permite redactar un correo electrónico nuevo.
  • SearchPage: muestra una vista de búsqueda.

router.dart

Primero, para comprender cómo se configura la navegación raíz de la app, abre router.dart en el directorio lib:

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

Este es el navegador raíz, que controla las pantallas de nuestra app que abarcan el lienzo completo, como HomePage y SearchPage. Además, detecta el estado de nuestra app para verificar si configuramos la ruta a ReplySearchPath. Si es así, vuelve a compilar el navegador con la SearchPage en la parte superior de la pila. Observa que las pantallas están unidas a un elemento CustomTransitionPage sin transiciones definidas. De esta manera, te mostramos una manera de navegar entre las pantallas sin ninguna transición personalizada.

home.dart

Para configurar la ruta en ReplySearchPath en el estado de nuestra app, haz lo siguiente dentro de _BottomAppBarActionItems en 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();
   },
 ),
);

En el parámetro onPressed, accedemos al elemento RouterProvider y configuramos su objeto routePath como ReplySearchPath. El elemento RouterProvider realiza un seguimiento del estado de los navegadores raíz.

mail_view_router.dart

Ahora, analicemos cómo está configurada la navegación interna de nuestra app. Abre mail_view_router.dart en el directorio lib. Verás un navegador similar al que se mostró anteriormente:

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

Este es nuestro navegador interno. Controla las pantallas internas de nuestra app que abarcan solo el cuerpo del lienzo, como la InboxPage. El elemento InboxPage muestra una lista de correos electrónicos en función del buzón actual en el estado de la app. El navegador se vuelve a compilar con el elemento InboxPage correcto en la parte superior de la pila, siempre que haya un cambio en la propiedad currentlySelectedInbox del estado de nuestra app.

home.dart

Para configurar el buzón actual en el estado de nuestra app, haz lo siguiente dentro de _HomePageState en 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(() {});
}

En la función _onDestinationSelected, accedemos al elemento EmailStore y configuramos su objeto currentlySelectedInbox en el destino seleccionado. El elemento EmailStore realiza un seguimiento del estado de los navegadores internos.

home.dart

Por último, para ver un ejemplo de un enrutamiento de navegación en uso, abre home.dart en el directorio lib. Ubica la clase _ReplyFabState, dentro de la propiedad onTap del widget InkWell, que debería verse de la siguiente manera:

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

Esto demuestra cómo navegar a la página para redactar correos electrónicos, sin ninguna transición personalizada. Durante este codelab, profundizarás en el código de Reply para configurar transiciones de Material que funcionen en conjunto con las varias acciones de navegación en toda la app.

Ahora que te familiarizaste con el código de partida, implementemos la primera transición.

5. Agrega una transición de transformación de contenedores desde la lista de direcciones de correo electrónico hasta la página de detalles del correo electrónico

En principio, agregarás una transición para la acción de hacer clic en un correo electrónico. Para este cambio de navegación, se prefiere el patrón de transformación de contenedores, ya que está diseñado para hacer transiciones entre elementos de la IU que incluyen un contenedor. Este patrón crea una conexión visible entre dos elementos de la IU.

Antes de agregar un fragmento de código, prueba ejecutar la app de Reply y hacer clic en un correo electrónico. Debería observarse un corte abrupto y simple, lo que implica que la pantalla se reemplaza por una sin transición:

Antes

48b00600f73c7778.gif

Comienza agregando una importación para el paquete de animaciones en la parte superior de mail_card_preview.dart, como se muestra en el siguiente fragmento:

mail_card_preview.dart

import 'package:animations/animations.dart';

Ahora que tienes una importación para el paquete de animaciones, podemos comenzar a agregar transiciones atractivas a tu app. Comencemos creando una clase StatelessWidget que alojará el widget OpenContainer.

En mail_card_preview.dart, agrega el siguiente fragmento de código después de la definición de la clase 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,
       );
     },
   );
 }
}

Ahora, usemos el wrapper nuevo. Dentro de la definición de la clase MailPreviewCard, uniremos el widget Material desde la función build() con el nuevo elemento _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(
...

_OpenContainerWrapper tiene un widget de InkWell y las propiedades de color de OpenContainer definen el color del contenedor que encierra. Por lo tanto, podemos quitar los widgets de InkWell y Material. El código resultante se ve así:

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

En esta etapa, la transformación de contenedores debe funcionar por completo. Cuando haces clic en un correo electrónico, el elemento de la lista se expande en una pantalla de detalles y, al mismo tiempo, se desvanece la lista de correos electrónicos. Cuando presionas el botón Atrás, la pantalla de detalles del correo electrónico se contrae en un elemento de la lista mientras se escala verticalmente la lista de correos electrónicos.

Después

663e8594319bdee3.gif

6. Agrega una transición de transformación de contenedores desde el BAF hasta la página para redactar correos electrónicos

Continuemos con la transformación de contenedores y agreguemos una transición desde el botón de acción flotante (BAF) hasta ComposePage expandiendo el BAF a un correo electrónico nuevo que escribirá el usuario. Primero, vuelve a ejecutar la app y haz clic en el BAF a fin de verificar que no se hagan transiciones cuando se inicia la pantalla para redactar correos electrónicos.

Antes

4aa2befdc5170c60.gif

El método de configuración de esta transición será muy similar al que hicimos en el último paso, ya que usamos la misma clase de widget, OpenContainer.

En home.dart, importemos package:animations/animations.dart en la parte superior del archivo y modifiquemos el método de _ReplyFabState build(). Unamos el widget de Material que se muestra con uno de 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 Material(
     color: theme.colorScheme.secondary,
     ...

Además de los parámetros que se usaron para configurar el widget OpenContainer anterior, ahora también se configura onClosed. onClosed es un elemento ClosedCallback al que se llama cuando se resalta la ruta OpenContainer o cuando vuelve al estado cerrado. El valor que se muestra de esa transacción se pasa a esta función como un argumento. Usamos esta Callback para notificarle al proveedor de nuestra app que dejamos la ruta de ComposePage, de modo que pueda notificar a todos los objetos de escucha.

De forma similar a lo que hicimos para el último paso, quitaremos el widget de Material de nuestro widget, ya que el widget de OpenContainer controla el color del widget que muestra el closedBuilder con closedColor. Además, quitaremos la llamada Navigator.push() dentro de la acción onTap del widget InkWell y la reemplazaremos por la openContainer() Callback que brinda el closedBuilder del widget OpenContainer, ya que ahora OpenContainer controla su propio enrutamiento.

El código resultante quedará así:

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

Ahora, borra algunos códigos anteriores. Como el widget OpenContainer ahora controla el proceso para notificarle al proveedor de nuestra app que ya no estamos en la ComposePage a través de una onClosed ClosedCallback, podemos quitar la implementación anterior en 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);

Ya terminaste con este paso. Deberías ver una transición desde el BAF hasta la pantalla para redactar de la siguiente manera:

Después

5c7ad1b4b40f9f0c.gif

7. Agrega una transición de eje Z compartido desde el ícono de búsqueda hasta la página de la vista de búsqueda

En este paso, agregaremos una transición desde el ícono de búsqueda hasta la vista de búsqueda directa en pantalla completa. Como no existe un contenedor persistente en este cambio de navegación, se puede usar una transición de eje Z compartido para reforzar la relación espacial entre las dos pantallas y para indicar que se mueva un nivel en dirección ascendente en la jerarquía de la app.

Antes de agregar más fragmentos de código, prueba ejecutar la app y presionar el ícono de búsqueda en la esquina inferior derecha de la pantalla. Debería aparecer la pantalla de la vista de búsqueda sin transición.

Antes

df7683a8ad7b920e.gif

Para comenzar, ve al archivo router.dart. Después de la definición de la clase ReplySearchPath, agrega el siguiente fragmento:

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

Ahora, usemos el nuevo SharedAxisTransitionPageWrapper para obtener la transición que deseamos. Dentro de la definición de la clase ReplyRouterDelegate, en la propiedad pages, unamos la búsqueda directa con un SharedAxisTransitionPageWrapper en lugar de una 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(),
     ),
 ],
);

Ahora, prueba volver a ejecutar la app.

81b3ea098926931.gif

Todo comienza a verse increíble. Cuando haces clic en el ícono de búsqueda, en la barra inferior de la app, la transición de eje compartido expande la escala de la página de búsqueda hasta que sea visible. Sin embargo, observa cómo no se reduce la escala de la página principal y, en su lugar, se mantiene estática, a medida que se expande la escala de la página de búsqueda sobre la otra. Además, cuando presionas el botón Atrás, no se expande la escala de la página principal hasta que sea visible; en cambio, se mantiene estática, a medida que se reduce la escala de la página de búsqueda. Esto significa que aún no terminamos.

Corrijamos ambos problemas uniendo la HomePage con SharedAxisTransitionWrapper en lugar de 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(),
     ),
 ],
);

Eso es todo. Ahora, prueba volver a ejecutar la app y presionar el ícono de búsqueda. Las pantallas de inicio y de la vista de búsqueda deben atenuarse y escalar, de manera simultánea, en el eje Z en profundidad. De esta manera, se crea un efecto fluido entre las dos pantallas.

Después

462d890086a3d18a.gif

8. Agrega una transición de atenuación entre las páginas de los buzones

En este paso, agregaremos una transición entre diferentes buzones. Como no deseamos enfatizar una relación espacial ni jerárquica, usaremos un fundido para realizar un "intercambio" sencillo entre las listas de correos electrónicos.

Antes de agregar más fragmentos de código, prueba ejecutar la app, presiona el logotipo de Reply en la barra inferior de la aplicación y cambia los buzones. La lista de correos electrónicos debería cambiar sin transición.

Antes

89033988ce26b92e.gif

Para comenzar, ve al archivo mail_view_router.dart. Después de la definición de la clase MailViewRouterDelegate, agrega el siguiente fragmento:

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

De manera similar al último paso, usemos el nuevo FadeThroughTransitionPageWrapper para obtener la transición que deseamos. Dentro de la definición de la clase MailViewRouterDelegate, en la propiedad pages, en lugar de unir la pantalla de los buzones con CustomTransitionPage, usa 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),
   ),
 ],
);

Vuelve a ejecutar la app. Cuando abres el panel lateral inferior de navegación y cambias los buzones, se atenúa la lista actual de correos electrónicos y se reduce su escala, mientras que la lista nueva se atenúa y su escala se expande. ¡Genial!

Después

8186940082b630d.gif

9. Agrega una transición de atenuación entre el BAF para redactar y para responder

En este paso, agregaremos una transición entre diferentes íconos del BAF. Como no deseamos enfatizar una relación espacial ni jerárquica, usaremos un fundido para realizar un "intercambio" sencillo entre los íconos en el BAF.

Antes de agregar más fragmentos de código, prueba ejecutar la app, presionar un correo electrónico y abrir la vista de correo electrónico. El ícono del BAF debería cambiar sin una transición.

Antes

d8e3afa0447cfc20.gif

Trabajaremos en home.dart para el resto del codelab, así que no te preocupes por agregar la importación para el paquete de animaciones, ya que lo hicimos para home.dart en el paso 2.

La manera en que configuramos las siguientes dos transiciones será muy similar, ya que todas nos brindarán una clase _FadeThroughTransitionSwitcher reutilizable.

En home.dart, agregaremos el siguiente fragmento en _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,
   );
 }
}

Ahora, en _ReplyFabState, busca el widget fabSwitcher. fabSwitcher muestra un ícono diferente en función de si es una vista de correo electrónico o no. Unámoslo con _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,
             ),
     );
...

Le brindamos al elemento _FadeThroughTransitionSwitcher un fillColor transparente, de modo que no se muestre ningún fondo entre los elementos durante la transición. También creamos una UniqueKey y la asignamos a uno de los íconos.

Ahora, en este paso, deberías observar un BAF contextual con una animación completa. Cuando se abre una vista de correo electrónico, se atenúa el ícono anterior del BAF y se reduce su escala, mientras que la vista nueva se atenúa y su escala se expande.

Después

c55bacd9a144ec69.gif

10. Agrega una transición de atenuación entre el título del buzón que desaparece

En este paso, agregaremos una transición de atenuación para el título del buzón entre un estado visible e invisible en una vista de correo electrónico. Como no deseamos enfatizar una relación espacial ni jerárquica, usaremos un fundido para realizar un "intercambio" sencillo entre el widget Text que abarca el título del buzón y un elemento SizedBox vacío.

Antes de agregar más fragmentos de código, prueba ejecutar la app, presionar un correo electrónico y abrir la vista de correo electrónico. El título del buzón debería desaparecer sin una transición.

Antes

59eb57a6c71725c0.gif

El resto de este codelab será rápido, porque, para el último paso, ya hicimos la mayor parte del trabajo en _FadeThroughTransitionSwitcher.

Ahora, vayamos a la clase _AnimatedBottomAppBar en home.dart para agregar la transición. Volveremos a utilizar _FadeThroughTransitionSwitcher del último paso y uniremos el condicional onMailView, que muestra un objeto SizedBox vacío o un título de buzón que se atenúa en sincronización con el panel lateral inferior:

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

Eso es todo. Terminamos este paso.

Vuelve a ejecutar la app. Cuando abras un correo electrónico y te dirijas a la vista de correo electrónico, se debería atenuar el título del buzón en la barra inferior de la aplicación, y debería reducirse su escala. ¡Genial!

Después

3f1a3db01a481124.gif

11. Agrega una transición de atenuación entre las acciones de la barra inferior de la aplicación

En este paso, agregaremos una transición de atenuación para las acciones de la barra inferior de la aplicación según el contexto de las aplicaciones. Como no deseamos enfatizar una relación espacial ni jerárquica, usaremos un fundido para realizar un "intercambio" sencillo entre las acciones de la barra inferior de la aplicación cuando la app esté en la página inicial, cuando el panel lateral inferior esté visible y cuando estemos en la vista de correo electrónico.

Antes de agregar más fragmentos de código, prueba ejecutar la app, presionar un correo electrónico y abrir la vista de correo electrónico. También, intenta presionar el logotipo de Reply. Las acciones de la barra inferior de la app deben cambiar sin una transición.

Antes

5f662eac19fce3ed.gif

De manera similar al último paso, volveremos a usar _FadeThroughTransitionSwitcher. Para lograr la transición deseada, ve a la definición de la clase _BottomAppBarActionItems y une el widget que se muestra de la función build() con _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
...

Ahora, probémoslo. Cuando abras un correo electrónico y te dirijas a la vista de correo electrónico, se atenuarán las acciones anteriores de la barra inferior de la aplicación y se reducirán sus escalas, mientras que se atenuarán las acciones nuevas y sus escalas se expandirán. ¡Bien hecho!

Después

cff0fa2afa1c5a7f.gif

12. ¡Felicitaciones!

Con menos de 100 líneas de código en Dart, el paquete de animaciones te permitió crear transiciones atractivas en una app existente que cumple con los lineamientos de Material Design y también tiene un aspecto y comportamiento coherentes en todos los dispositivos.

d5637de49eb64d8a.gif

Próximos pasos

Si deseas obtener más información sobre el sistema de movimiento de Material, asegúrate de consultar la especificación y la documentación completa para desarrolladores, y prueba agregar algunas transiciones de Material a tu app.

Gracias por probar el sistema de movimiento de Material. Esperamos que hayas disfrutado de este codelab.

Pude completar este codelab en una cantidad de tiempo y con un nivel de esfuerzo razonables

Totalmente de acuerdo De acuerdo Neutral En desacuerdo Totalmente en desacuerdo

Me gustaría seguir usando el sistema de movimiento de Material en el futuro.

Totalmente de acuerdo De acuerdo Neutral En desacuerdo Totalmente en desacuerdo

Para ver más demostraciones sobre cómo usar los widgets que brinda la biblioteca de Material de Flutter y el framework de Flutter, asegúrate de visitar la galería de Flutter.

46ba920f17198998.png

6ae8ae284bf4f9fa.png