Como criar transições incríveis com o movimento do Material Design para o Flutter

1. Introdução

O Material Design é um sistema para criar produtos digitais bonitos e arrojados. Ao combinar estilo, branding, interação e movimento em um conjunto consistente de princípios e componentes, as equipes de produto podem atingir o maior potencial de design.

logo_components_color_2x_web_96dp.png

Os componentes do Material Design (MDC, na sigla em inglês) ajudam os desenvolvedores a implementar o Material Design. Criados por uma equipe de engenheiros e designers de UX do Google, os MDC oferecem dezenas de componentes de IU bonitos e funcionais disponíveis para Android, iOS, Web e Flutter. material.io/develop

O que é o sistema de movimento do Material Design para o Flutter?

O sistema de movimento do Material Design para o Flutter é um conjunto de padrões de transição do pacote de animações. Ele ajuda os usuários a entender e navegar em um app, conforme descrito nas diretrizes do Material Design.

Veja os quatro padrões principais de transição do Material Design:

  • Transformação de contêiner: faz a transição entre elementos da IU que incluem um contêiner. Cria uma conexão visível entre dois elementos ao transformar um no outro.

11807bdf36c66657.gif

  • Eixo compartilhado: faz a transição entre elementos da IU que têm uma relação espacial ou de navegação. Usa uma transformação compartilhada nos eixos x, y ou z para reforçar a relação entre os elementos.

71218f390abae07e.gif

  • Esmaecimento cruzado: faz a transição entre elementos da IU que não têm uma relação forte entre si. Usa o esmaecimento e a exibição gradual de forma sequencial com uma escala do elemento de entrada.

385ba37b8da68969.gif

  • Esmaecimento: usado para elementos da IU que entram ou saem dos limites da tela.

cfc40fd6e27753b6.gif

O pacote de animações oferece widgets de transição para esses padrões, criados com base na biblioteca de animações do Flutter (flutter/animation.dart) e na biblioteca do Material Design para o Flutter (flutter/material.dart) (links em inglês):

Neste codelab, você usará as transições do Material Design com base no framework do Flutter e na biblioteca do Material Design, ou seja, você trabalhará com widgets. :)

O que você criará

Este codelab orientará você na criação de algumas transições em um app de e-mails de exemplo do Flutter chamado Reply. Usaremos o Dart para demonstrar como usar as transições do pacote de animações para personalizar a aparência e o uso do app.

Você receberá o código inicial do app Reply para incorporar nele as seguintes transições do Material Design, que podem ser vistas no GIF do codelab completo abaixo:

  • Transição de transformação de contêiner da lista de e-mails para a página de detalhes
  • Transição de transformação de contêiner do FAB para a página "Escrever e-mail"
  • Transição de eixo z compartilhado do ícone de pesquisa para a página de visualização de pesquisa
  • Transição de esmaecimento cruzado entre as páginas da caixa de e-mails
  • Transição de esmaecimento cruzado entre o FAB de escrever e o de responder
  • Transição de esmaecimento cruzado entre títulos da caixa de e-mails que desaparecem
  • Transição de esmaecimento cruzado entre as ações da barra de apps inferior

b26fe84fed12d17d.gif

Pré-requisitos

  • Conhecimento básico de desenvolvimento no Flutter e Dart
  • Um editor de código
  • Um emulador ou dispositivo Android/iOS
  • O exemplo de código (confira a próxima etapa)

Como você classificaria seu nível de experiência na criação de apps Flutter?

Iniciante Intermediário Proficiente

O que você quer aprender com este codelab?

Ainda não conheço bem o assunto e quero ter uma boa visão geral. Conheço um pouco sobre esse assunto, mas quero me atualizar. Estou procurando exemplos de código para usar no meu projeto. Estou procurando uma explicação de algo específico.

2. Configurar o ambiente de desenvolvimento do Flutter

Você precisa de dois softwares para concluir este laboratório: o SDK do Flutter e um editor.

É possível executar o codelab usando qualquer um destes dispositivos:

  • Um dispositivo físico Android ou iOS conectado ao seu computador e configurado para o modo de desenvolvedor.
  • O simulador para iOS, que exige a instalação de ferramentas do Xcode.
  • O Android Emulator, que requer configuração no Android Studio.
  • Um navegador (o Chrome é necessário para depuração).
  • Como um aplicativo para computador Windows, Linux ou macOS. Você precisa desenvolver na plataforma em que planeja implantar. Portanto, se quiser desenvolver um app para um computador Windows, você terá que desenvolver no Windows para acessar a cadeia de compilação adequada. Há requisitos específicos de cada sistema operacional que são abordados em detalhes em docs.flutter.dev/desktop.

3. Fazer o download do app inicial do codelab

Opção 1: clonar o app inicial do codelab no GitHub

Para clonar este codelab no GitHub, execute estes comandos:

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

Opção 2: fazer o download do arquivo ZIP do app inicial do codelab

O app inicial está localizado no diretório material-components-flutter-motion-codelab-starter.

Verificar as dependências do projeto

O projeto depende do pacote de animações. Em pubspec.yaml, verifique se a seção dependencies inclui o seguinte:

animations: ^2.0.0

Abrir o projeto e executar o app

  1. Abrir o projeto no seu editor favorito.
  2. Siga as instruções para "Executar o app" em Get Started: Test drive no editor escolhido.

Pronto. O código inicial da página inicial do Reply será executado no dispositivo/emulador. Você vai acessar a Caixa de entrada com uma lista de e-mails.

Página inicial do Reply

Opcional: tornar as animações do dispositivo mais lentas

Como este codelab envolve transições rápidas, mas refinadas, pode ser útil desacelerar as animações do dispositivo para observar alguns dos detalhes mais delicados das transições durante a implementação. Isso pode ser feito com uma configuração no app, que pode ser acessada por um toque no ícone de configurações quando a gaveta inferior está aberta. Não se preocupe, esse método de desaceleramento de animações do dispositivo não afetará as animações no dispositivo fora do app Reply.

d23a7bfacffac509.gif

Opcional: modo escuro

Se o tema claro do Reply está incomodando seus olhos, a solução chegou. Há uma configuração incluída no app que permite mudar o tema para o modo escuro, para que haja mais conforto para seus olhos. Para acessar essa configuração, toque no ícone de configurações quando a gaveta inferior estiver aberta.

87618d8418eee19e.gif

4. Conhecer o código do app de exemplo

Vamos analisar o código. Fornecemos um app que usa o pacote de animações (link em inglês) para fazer transições entre diferentes telas no aplicativo.

  • HomePage: exibe a caixa de e-mails selecionada.
  • InboxPage: exibe uma lista de e-mails.
  • MailPreviewCard: exibe a visualização de um e-mail.
  • MailViewPage: exibe um único e-mail completo.
  • ComposePage: permite escrever um novo e-mail.
  • SearchPage: exibe uma visualização de pesquisa.

router.dart

Primeiro, para entender como a navegação raiz do app é configurada, abra o router.dart no diretório 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);
 }
}

Esse é o navegador raiz, que processa as partes do app que ocupam a tela inteira, como HomePage e SearchPage. Ele detecta o estado do app para conferir se a rota foi definida como ReplySearchPath. Em caso positivo, ele recriará o navegador com a tela SearchPage na parte superior da pilha. Observe que nossas telas são envolvidas em uma CustomTransitionPage sem transições definidas. Isso demonstra uma forma de navegar entre as telas sem transições personalizadas.

home.dart

Para definir nossa rota como ReplySearchPath no estado do app, fazemos o seguinte dentro de _BottomAppBarActionItems no 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();
   },
 ),
);

No parâmetro onPressed, acessamos nosso RouterProvider e definimos o routePath como ReplySearchPath. Nosso RouterProvider monitora o estado dos nossos navegadores raiz.

mail_view_router.dart

Agora, vamos ver como a navegação interna do app está configurada. Abra o mail_view_router.dart no diretório lib. Você verá um navegador semelhante ao de cima:

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

Esse é nosso navegador interno. Ele processa as imagens internas do app que consomem apenas o corpo da tela, como a InboxPage. A InboxPage exibe uma lista de e-mails que depende de qual caixa de e-mails está no estado do app. O navegador é recriado com a InboxPage correta no topo da pilha sempre que há mudança na propriedade currentlySelectedInbox do estado do app.

home.dart

Para definir nossa caixa de e-mails atual no estado do app, fazemos o seguinte dentro do _HomePageState no 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(() {});
}

Na função _onDestinationSelected, acessamos o EmailStore e definimos a currentlySelectedInbox como o destino selecionado. Nosso EmailStore monitora o estado dos nossos navegadores internos.

home.dart

Por fim, para ver um exemplo de rota de navegação em uso, abra o home.dart no diretório lib. Localize a classe _ReplyFabState dentro da propriedade onTap do widget InkWell, que terá esta aparência:

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

Isso mostra como você pode navegar para a página "Escrever e-mail" sem nenhuma transição personalizada. Durante este codelab, você conhecerá o código do Reply para configurar transições do Material Design que funcionam em conjunto com as várias ações de navegação do app.

Agora que você já conhece o código inicial, vamos implementar nossa primeira transição.

5. Adicionar uma transição de transformação de contêiner da lista de e-mails para a página de detalhes

Para começar, adicione uma transição ao clicar em um e-mail. Para essa mudança de navegação, o padrão de transformação de contêiner é adequado, já que foi projetado para transições entre elementos da IU que incluem um contêiner. Ele cria uma conexão visível entre dois elementos da IU.

Antes de adicionar qualquer código, execute o app Reply e clique em um e-mail. Ele fará um simples corte, ou seja, a tela será substituída sem transição:

Antes

48b00600f73c7778.gif

Para começar, adicione uma importação ao pacote de animações na parte superior do mail_card_preview.dart, como mostrado no snippet a seguir:

mail_card_preview.dart

import 'package:animations/animations.dart';

Agora que você tem uma importação para o pacote de animações, podemos começar a adicionar transições bonitas ao app. Primeiro, crie uma classe StatelessWidget para hospedar nosso widget OpenContainer.

No mail_card_preview.dart, adicione o seguinte snippet de código após a definição de classe do 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,
       );
     },
   );
 }
}

Agora, vamos usar o novo wrapper. Dentro da definição da classe MailPreviewCard, envolveremos o widget Material da função build() com o novo _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 tem um widget do InkWell e as propriedades de cor do OpenContainer definem a cor do contêiner. Assim, podemos remover os widgets Material e Inkwell. O código resultante será parecido com este:

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

Nesta fase, você já tem uma transformação de contêiner em funcionamento. Se você clicar em um e-mail, o item da lista será expandido para uma tela de detalhes enquanto a lista de e-mails for ocultada. Se você pressionar "Voltar", a tela de detalhes do e-mail será recolhida e a lista de e-mails voltará a aparecer.

Depois

663e8594319bdee3.gif

6. Adicionar uma transição de transformação de contêiner do FAB para a página "Escrever e-mail"

Vamos continuar a transformação de contêiner e adicionar uma transição do botão de ação flutuante para a ComposePage, expandindo o FAB para um novo e-mail que será escrito pelo usuário. Primeiro, execute o app novamente e clique no FAB. Você verá que não há transição ao abrir a tela "Escrever e-mail".

Antes

4aa2befdc5170c60.gif

Vamos configurar essa transição de maneira muito semelhante ao que fizemos na etapa anterior, já que estamos usando a mesma classe de widget, a OpenContainer.

Em home.dart, vamos importar package:animations/animations.dart na parte de cima do arquivo e modificar o método _ReplyFabState build(). Vamos combinar o widget Material com o 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,
     ...

Além dos parâmetros usados para configurar o widget OpenContainer anterior, o onClosed também será definido. O onClosed é um ClosedCallback chamado quando a rota OpenContainer é exibida ou quando ela retorna ao estado fechado. O valor de retorno dessa transação é transmitido à função como um argumento. Usamos esse Callback para notificar o provedor do app sobre a saída da rota ComposePage. Assim, ele pode notificar todos os listeners.

Assim como fizemos na etapa anterior, removeremos o widget Material do nosso widget, já que o OpenContainer processa a cor do widget retornado pelo closedBuilder com closedColor. Removeremos também a chamada Navigator.push() dentro do onTap do widget InkWell, substituindo-a pelo openContainer() Callback fornecido pelo closedBuilder do widget OpenContainer, já que agora o OpenContainer processa a própria rota.

O código resultante será parecido com este:

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

Agora, vamos limpar parte do código antigo. Como o widget OpenContainer agora processa a notificação do provedor do app sobre a saída da ComposePage pelo onClosed ClosedCallback, podemos remover a implementação anterior no 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);

Pronto, concluímos esta etapa. Agora você tem uma transição do FAB para a tela "Escrever e-mail" com a seguinte aparência:

Depois

5c7ad1b4b40f9f0c.gif

7. Adicionar uma transição de eixo z compartilhado do ícone de pesquisa para a página de visualização de pesquisa

Nesta etapa, adicionaremos uma transição do ícone de pesquisa para a visualização de pesquisa em tela cheia. Como não há um contêiner persistente envolvido nessa mudança de navegação, podemos usar uma transição de eixo z compartilhado para reforçar a relação espacial entre as duas telas e indicar o movimento para um nível acima na hierarquia do app.

Antes de adicionar mais código, execute o app e toque no ícone de pesquisa no canto inferior direito da tela. Essa ação exibirá a tela de visualização da pesquisa sem transição.

Antes

df7683a8ad7b920e.gif

Para começar, acesse o arquivo router.dart. Depois que definir a classe ReplySearchPath, adicione o seguinte 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;
       });
 }
}

Agora, usaremos o novo SharedAxisTransitionPageWrapper para criar a transição desejada. Dentro da definição da classe ReplyRouterDelegate na propriedade pages, vamos combinar a tela de pesquisa com um SharedAxisTransitionPageWrapper em vez 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 CustomTransitionPage(
     transitionKey: ValueKey('Home'),
     screen: HomePage(),
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('Search'),
       screen: SearchPage(),
     ),
 ],
);

Agora, execute o app novamente.

81b3ea098926931.gif

Tudo está ficando ótimo. Quando você clica no ícone de pesquisa na barra de apps inferior, uma transição de eixo compartilhado redimensiona a página de pesquisa para que ela seja exibida. No entanto, observe como a página inicial não é redimensionada, permanecendo estática à medida que a página de pesquisa aumenta sobre ela. Além disso, quando o botão "Voltar" é pressionado, a página inicial não é redimensionada para a visualização: ela fica parada enquanto a página de pesquisa é recolhida. Ou seja, o trabalho ainda não terminou.

Vamos resolver os dois problemas ao combinar HomePage com nosso SharedAxisTransitionWrapper em vez 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(),
     ),
 ],
);

Pronto! Agora, execute novamente o app e toque no ícone de pesquisa. As telas inicial e de visualização da pesquisa serão esmaecidas e redimensionadas simultaneamente ao longo do eixo z em profundidade, criando um efeito contínuo entre as duas telas.

Depois

462d890086a3d18a.gif

8. Adicionar uma transição de esmaecimento cruzado entre as páginas da caixa de e-mails

Nesta etapa, adicionaremos uma transição entre caixas de e-mails diferentes. Como não queremos enfatizar uma relação espacial ou hierárquica, usaremos um esmaecimento cruzado para fazer uma "troca" simples entre as listas de e-mails.

Antes de adicionar qualquer outro código, tente executar o app. Toque no logotipo do Reply na barra de apps inferior e mude de caixa de e-mails. A lista mudará sem nenhuma transição.

Antes

89033988ce26b92e.gif

Para começar, acesse o arquivo mail_view_router.dart. Depois que definir a classe MailViewRouterDelegate, adicione o seguinte 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;
       });
 }
}

Assim como fizemos na etapa anterior, vamos usar o novo FadeThroughTransitionPageWrapper para criar a transição desejada. Dentro da definição da classe MailViewRouterDelegate, na propriedade pages, em vez de combinar nossa tela de caixa de e-mails com um CustomTransitionPage, use 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),
   ),
 ],
);

Execute o app novamente. Quando você abrir a gaveta de navegação inferior e mudar de caixa de e-mails, a lista de e-mails atual será esmaecida enquanto a nova lista é exibida. Muito bem!

Depois

8186940082b630d.gif

9. Adicionar uma transição de esmaecimento cruzado entre o FAB de escrever e o de responder

Nesta etapa, adicionaremos uma transição entre diferentes ícones do FAB. Como não queremos enfatizar uma relação espacial ou hierárquica, usaremos um esmaecimento cruzado para fazer uma "troca" simples entre os ícones do FAB.

Antes de adicionar qualquer outro código, tente executar o app. Toque em um e-mail e abra a visualização de e-mail. O ícone do FAB mudará sem uma transição.

Antes

d8e3afa0447cfc20.gif

Trabalharemos no home.dart no restante do codelab. Portanto, não se preocupe em adicionar a importação do pacote de animações, porque já fizemos isso no home.dart na etapa 2.

Vamos configurar as próximas duas transições de maneira muito semelhante, já que todas elas criarão com base em uma classe reutilizável, _FadeThroughTransitionSwitcher.

No home.dart, adicione o seguinte snippet no _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,
   );
 }
}

Agora, no _ReplyFabState, procure o widget fabSwitcher. fabSwitcher retorna um ícone diferente dependendo da visualização do e-mail (se estiver ativada ou não). Vamos fazer a combinação com _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,
             ),
     );
...

Fornecemos ao _FadeThroughTransitionSwitcher um fillColor transparente para que não haja um plano de fundo entre os elementos durante a transição. Também criamos o UniqueKey e o atribuímos a um dos ícones.

Agora, nesta etapa, você terá um FAB contextual animado. Quando você entra em uma visualização de e-mail, o ícone do FAB antigo é ocultado enquanto o novo é exibido.

Depois

c55bacd9a144ec69.gif

10. Adicionar uma transição de esmaecimento cruzado entre títulos da caixa de e-mails que desaparecem

Nesta etapa, adicionaremos uma transição de esmaecimento cruzado para mudar o título da caixa de e-mails entre o estado visível e o invisível durante a visualização de um e-mail. Como não queremos enfatizar uma relação espacial ou hierárquica, usaremos um esmaecimento cruzado para fazer uma "troca" simples entre o widget Text, que inclui o título da caixa de e-mails, e um SizedBox vazio.

Antes de adicionar qualquer outro código, tente executar o app. Toque em um e-mail e abra a visualização de e-mail. O título da caixa de e-mails desaparecerá sem uma transição.

Antes

59eb57a6c71725c0.gif

O restante deste codelab será rápido, já que fizemos a maior parte do trabalho no _FadeThroughTransitionSwitcher na etapa anterior.

Agora, vamos acessar a classe _AnimatedBottomAppBar no home.dart para adicionar a transição. Reutilizaremos o _FadeThroughTransitionSwitcher da última etapa e envolveremos nossa condicional onMailView, que retorna um SizedBox vazio ou um título da caixa de e-mails que é esmaecido em sincronia com a gaveta 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,
                   ),
             );
           },
         ),
       ),
),

Pronto. Você concluiu esta etapa.

Execute o app novamente. Ao abrir um e-mail e acessar a visualização dele, o título da caixa de e-mails na barra de apps inferior será esmaecido e ocultado. Incrível!

Depois

3f1a3db01a481124.gif

11. Adicionar uma transição de esmaecimento cruzado entre as ações da barra de apps inferior

Nesta etapa, adicionaremos uma transição de esmaecimento cruzado para esmaecer as ações da barra de apps inferior com base no contexto do aplicativo. Como não queremos enfatizar uma relação espacial ou hierárquica, usaremos um esmaecimento cruzado para fazer uma "troca" simples entre as ações da barra de apps inferior quando o app está na HomePage, quando a gaveta inferior está visível e quando estamos na visualização de e-mail.

Antes de adicionar qualquer outro código, tente executar o app. Toque em um e-mail e abra a visualização de e-mail. Você também pode tocar no logotipo do Reply. As ações da barra de apps inferior mudarão sem uma transição.

Antes

5f662eac19fce3ed.gif

Assim como na etapa anterior, usaremos o _FadeThroughTransitionSwitcher novamente. Para conseguir a transição desejada, acesse a definição da classe _BottomAppBarActionItems e envolva o widget de retorno da função build() com um _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
...

Vamos fazer um teste. Ao abrir um e-mail e acessar a visualização dele, as ações anteriores da barra de apps inferior serão esmaecidas e ocultadas e as novas ações serão exibidas. Muito bem!

Depois

cff0fa2afa1c5a7f.gif

12. Parabéns!

Usando menos de 100 linhas de código Dart, o pacote de animações ajudou você a criar transições bonitas em um app atual seguindo as diretrizes do Material Design. Além disso, a aparência e o comportamento das transições são consistentes em todos os dispositivos.

d5637de49eb64d8a.gif

Próximas etapas

Para ver mais informações sobre o sistema de movimento do Material Design, consulte as especificações e a documentação completa do desenvolvedor (links em inglês). Tente adicionar algumas transições do Material Design ao seu app.

Agradecemos por testar o movimento do Material Design. Esperamos que tenha gostado deste codelab.

Este codelab exigiu esforço e tempo normais para ser concluído

Concordo totalmente Concordo Não concordo nem discordo Discordo Discordo totalmente

Quero continuar usando o sistema de movimento do Material Design no futuro

Concordo totalmente Concordo Não concordo nem discordo Discordo Discordo totalmente

Para conferir mais demonstrações de como usar os widgets fornecidos pela biblioteca Flutter do Material Design, além do framework do Flutter, acesse a Galeria do Flutter.

46ba920f17198998.png

6ae8ae284bf4f9fa.png