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

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.

b9fd67c205755d55.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 X para reforzar la relación entre elementos.

76622de33a19179.gif

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

18a525c038443492.gif

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

cd10a0580a159644.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 fundido entre las acciones de la barra inferior de la aplicación

5f7b8860db2c70e2.gif

Requisitos

  • Conocimientos básicos sobre el desarrollo de Flutter y Dart
  • Android Studio (descárgalo aquí si todavía no lo tienes)
  • Un emulador o dispositivo Android (disponible a través de Android Studio)
  • 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.

Antes de comenzar

A fin de comenzar a desarrollar apps para dispositivos móviles con Flutter, sigue estos pasos:

  1. Descarga e instala el SDK de Flutter.
  2. Actualiza tu PATH con el SDK de Flutter.
  3. Instala Android Studio con los complementos de Flutter y Dart, o tu editor favorito.
  4. Instala un emulador de Android o un simulador de iOS (requiere una Mac con Xcode), o usa un dispositivo físico.

Para obtener más información sobre la instalación de Flutter, consulta Cómo comenzar: instalación. Para configurar un editor, consulta Cómo comenzar: configurar un editor. Cuando instales un emulador de Android, podrás usar las opciones predeterminadas, como un teléfono Pixel 3 con la imagen del sistema más reciente. Se recomienda, pero no es necesario, habilitar la aceleración de VM. Una vez que hayas completado los 4 pasos anteriores, podrás volver al codelab. Para completar este codelab, solo debes instalar Flutter en una plataforma (Android o iOS).

Asegúrate de que el SDK de Flutter esté en el estado correcto

Antes de continuar con este codelab, asegúrate de que el SDK esté en el estado correcto. Si el SDK de Flutter se instaló anteriormente, usa flutter upgrade para asegurarte de que el SDK esté en el estado más reciente.

 flutter upgrade

Si ejecutas flutter upgrade, se ejecutará automáticamente flutter doctor.. Si es una instalación nueva de Flutter y no necesitas actualizar nada, ejecuta flutter doctor de forma manual. Informará si hay dependencias que necesitas instalar para completar la configuración. Puedes ignorar las marcas de verificación que no sean relevantes para ti (por ejemplo, Xcode si no deseas desarrollar para iOS).

 flutter doctor

Preguntas frecuentes

Inicia Android Studio

Cuando abras Android Studio, debería aparecer una ventana con el título "Welcome to Android Studio". Sin embargo, si es la primera vez que inicias Android Studio, sigue los pasos del asistente de configuración de Android Studio con los valores predeterminados. En este paso, descargar e instalar los archivos necesarios puede tardar varios minutos. Puedes permitir que se ejecute en segundo plano mientras realizas los pasos de la siguiente sección.

Opción 1: Clona la app de inicio 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

Descargar app de inicio

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

Cómo cargar el código de inicio en Android Studio

  1. Una vez que finalice el asistente de configuración y aparezca la ventana Welcome to Android Studio, haz clic en Open an existing Android Studio project.

e3f200327a67a53.png

  1. Navega hasta el directorio en el que instalaste el código de muestra y selecciona el directorio de muestra para abrir el proyecto.
  2. Espera un momento para que Android Studio compile y sincronice el proyecto, como se muestra en los indicadores de actividad de la parte inferior de la ventana de Android Studio.
  3. En este punto, es posible que Android Studio genere algunos errores de compilación, ya que te faltan el SDK de Android o las herramientas de compilación, como se muestra más abajo. Sigue las instrucciones de Android Studio para instalar o actualizar estos elementos y sincronizar tu proyecto. Si todavía tienes problemas, sigue la guía para actualizar herramientas con SDK Manager.

6e026ae171f5b1eb.png

  1. Si se te solicita, haz lo siguiente:
  • Instala cualquier actualización de plataforma y complemento o FlutterRunConfigurationType.
  • Si el SDK de Dart o Flutter no está configurado, establece la ruta del SDK de Flutter para el complemento de Flutter.
  • Configura los frameworks de Android.
  • Haz clic en "Get dependencies" o "Run 'flutter packages get'".

Luego, reinicia Android Studio.

53b7195f1c1deedb.png

be5ce477ba09225e.png 24810642cf859588.png

Verifica las dependencias del proyecto

El proyecto necesita una dependencia en el paquete de animaciones. El código de muestra que descargaste ya debería tener esta dependencia, pero verifiquemos la configuración para asegurarnos.

Navega hasta el archivo pubspec.yaml del módulo app y asegúrate de que la sección dependencies incluya una dependencia en el paquete de animaciones:

animations: ^1.1.2

Cómo ejecutar la app de inicio

  1. Asegúrate de que la configuración de compilación ubicada a la izquierda de la elección de dispositivo sea app.
  2. Presiona el botón verde Run/Play para compilar y ejecutar la app.

a34cba7fab0a2af9.png

  1. Si ya aparece un dispositivo en los dispositivos disponibles, ve al paso 8 en el menú desplegable Flutter Device Selection, en la parte superior de la pantalla del editor. De lo contrario, haz clic en Create New Virtual Device.
  2. En la pantalla Select Hardware, selecciona un teléfono, por ejemplo, Pixel 3, y haz clic en Next.
  3. En la pantalla System Image, selecciona una versión reciente de Android, preferentemente, el nivel de API más alto. Si no está instalada, haz clic en el vínculo Download que aparece y completa la descarga.
  4. Haz clic en Next.
  5. En la pantalla Android Virtual Device (AVD), deja los ajustes tal como están y haz clic en Finish.
  6. Selecciona un dispositivo (por ejemplo, iPhone SE o Android SDK built for <version> del menú desplegable Flutter Device Selection).
  7. Presiona el ícono de Play (b8c998094aa23ac2.png).
  8. Android Studio compila la app, la implementa y la abre automáticamente en el dispositivo de destino.

Listo. El código de inicio para la página principal de Reply debe estar ejecutándose en tu emulador. Deberías ver la carpeta Recibidos con una lista de correos electrónicos.

Android

iOS

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.

Android

iOS

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 esta configuración, presiona el ícono de configuración cuando abres el panel lateral inferior.

Android

iOS

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})
     : assert(replyState != null),
       navigatorKey = GlobalObjectKey<NavigatorState>(replyState) {
   replyState.addListener(() {
     notifyListeners();
   });
 }

 @override
 final GlobalKey<NavigatorState> navigatorKey;

 RouterProvider replyState;

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

 @override
 ReplyRoutePath get currentConfiguration => replyState.routePath;

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

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

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

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

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 = 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({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 inicio, implementemos la primera transición.

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

Android

iOS

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,
 })  : assert(id != null),
       assert(email != null),
       assert(closedChild != null);

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

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

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

Ahora, usemos el wrapper nuevo. Dentro de la definición de la clase MailPreviewCard, uniremos el widget return 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(
     color: theme.cardColor,
     child: InkWell(
       onTap: () {
         Provider.of<EmailStore>(
           context,
           listen: false,
         ).currentlySelectedEmailId = id;

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

Asegúrate de quitar InkWell del widget, ya que su lógica ahora se encuentra dentro de la clase _OpenContainerWrapper. También, podemos quitar el widget Material, ya que las propiedades de color del OpenContainer definen el color del contenedor que encierra:

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

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

Android

iOS

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

Android

iOS

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, agrega el siguiente fragmento a la definición de la clase _ReplyFabState y asegúrate de importar package:animations/animations.dart en la parte superior del archivo. En este paso, unimos el widget que se muestra de la función build() de la definición de la clase _ReplyFabState con un widget 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,
     shape: circleFabBorder,
     child: Tooltip(
       message: tooltip,
       child: InkWell(
         customBorder: circleFabBorder,
         onTap: () {
           Provider.of<EmailStore>(
             context,
             listen: false,
           ).onCompose = true;

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

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.

En home.dart, dentro de la definición de la clase _ReplyFabState:

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

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

Android

iOS

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

Android

iOS

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})
      : assert(screen != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget screen;
  final ValueKey transitionKey;

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

Ahora, usemos el nuevo SharedAxisTransitionPageWrapper para obtener la transición que deseamos. Uniremos las pantallas del widget con el wrapper, de modo que muestre una ruta respaldada por una página para el navegador con la transición que deseamos. Dentro de la definición de la clase ReplyRouterDelegate, en la propiedad pages, en lugar de unir la pantalla de búsqueda con CustomTransitionPage, usa el wrapper nuevo:

router.dart

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

Ahora, prueba volver a ejecutar la app.

Android

iOS

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.

Para corregir las transiciones de la página de inicio, une la HomePage con el SharedAxisTransitionWrapper en router.dart:

router.dart

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

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

Android

iOS

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

Android

iOS

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 {
  FadeThroughTransitionPageWrapper({
    @required this.mailbox,
    @required this.transitionKey,
  })  : assert(mailbox != null),
        assert(transitionKey != null),
        super(key: transitionKey);

  final Widget mailbox;
  final ValueKey transitionKey;

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

De manera similar al último paso, usemos el nuevo FadeThroughTransitionPageWrapper para obtener la transición que deseamos. Uniremos las pantallas de los buzones con el wrapper, de modo que muestre una ruta respaldada por una página para el navegador con la transición de fundido. 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 el wrapper nuevo:

mail_view_router.dart

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

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

Android

iOS

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

Android

iOS

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,
 })  : assert(fillColor != null),
       assert(child != null);

 final Widget child;
 final Color fillColor;

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

Ahora, en _ReplyFabState, busca el widget fabSwitcher. El widget fabSwitcher es lo que permite que el BAF cambie según el contexto. El widget fabSwitcher verifica si estamos en una vista de correo electrónico y, de ser así, nos brindará un ícono diferente para el BAF.

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.

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

Android

iOS

En este paso, agregaremos una transición de fundido para atenuar 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

Android

iOS

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

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

Android

iOS

En este paso, agregaremos una transición de fundido para atenuar 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

Android

iOS

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
         ? Row(
             mainAxisSize: MainAxisSize.max,
             mainAxisAlignment: MainAxisAlignment.end,
             children: [
               IconButton(
                 icon: ImageIcon(
                   const AssetImage(
                     '$_iconAssetLocation/twotone_star.png',
                     package: _assetsPackage,
                   ),
                   color: starIconColor,
                 ),
                 onPressed: () {
                   model.starEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );
                   if (model.currentlySelectedInbox == 'Starred') {
                     mobileMailNavKey.currentState.pop();
                     model.currentlySelectedEmailId = -1;
                   }
                 },
                 color: ReplyColors.white50,
               ),
               IconButton(
                 icon: const ImageIcon(
                   AssetImage(
                     '$_iconAssetLocation/twotone_delete.png',
                     package: _assetsPackage,
                   ),
                 ),
                 onPressed: () {
                   model.deleteEmail(
                     model.currentlySelectedInbox,
                     model.currentlySelectedEmailId,
                   );

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

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

Android

iOS

Con menos de 100 líneas de código 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.

Android

iOS

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.

52f7119a30bb8f5c.png

dd11628e4c0f3fd3.png