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

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.

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

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

18a525c038443492.gif

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

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

5f7b8860db2c70e2.gif

Pré-requisitos

  • Conhecimento básico de desenvolvimento no Flutter e Dart
  • Android Studio (faça o download dele aqui, caso ainda não tenha feito)
  • Um dispositivo ou emulador Android (disponível no Android Studio)
  • O exemplo de código (veja 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.

Antes de começar

Para começar a desenvolver apps para dispositivos móveis usando o Flutter, siga estas etapas:

  1. Faça o download e instale o SDK do Flutter.
  2. Atualize seu PATH com o SDK do Flutter.
  3. Instale o Android Studio com os plug-ins do Flutter e do Dart ou o editor que você preferir.
  4. Instale um emulador do Android, um simulador do iOS (requer um Mac com Xcode) ou use um dispositivo físico.

Para ver mais informações sobre a instalação do Flutter, consulte Primeiros passos: instalação (link em inglês). Para configurar um editor, consulte Primeiros passos: configurar um editor (link em inglês). Você pode usar as opções padrão para instalar um emulador do Android, como um smartphone Pixel 3 com a imagem do sistema mais recente. Recomendamos ativar a aceleração de VM, embora não seja obrigatório. Depois de concluir as quatro etapas acima, retorne ao codelab. Para realizar este codelab, você só precisará instalar o Flutter para uma plataforma (Android ou iOS).

Conferir se o SDK do Flutter está no estado correto

Antes de continuar este codelab, veja se o SDK está no estado correto. Se o SDK do Flutter tiver sido instalado anteriormente, use o comando flutter upgrade para garantir que ele esteja no estado mais recente.

 flutter upgrade

O comando flutter upgrade executará o flutter doctor. automaticamente. Se esta for uma nova instalação do Flutter e nenhuma atualização for necessária, execute o flutter doctor manualmente. Você receberá uma notificação se for necessário instalar alguma dependência para concluir a configuração. Ignore as marcas de seleção que não forem relevantes para você (por exemplo, Xcode, se você não quiser desenvolver para iOS).

 flutter doctor

Perguntas frequentes

Iniciar o Android Studio

Ao abrir o Android Studio, você verá uma janela com o título "Welcome to Android Studio". No entanto, se você estiver iniciando essa ferramenta pela primeira vez, siga as etapas do assistente de configuração do Android Studio com os valores padrão. O sistema pode demorar vários minutos para fazer o download e instalar os arquivos necessários, então você pode deixar o programa em execução em segundo plano enquanto acompanha a próxima seção.

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

Fazer o download do app inicial

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

Carregar o código inicial no Android Studio

  1. Quando o assistente de configuração for concluído e a janela Welcome to Android Studio for exibida, clique em Open an existing Android Studio project.

e3f200327a67a53.png

  1. Acesse o diretório em que você instalou o exemplo de código e selecione o exemplo de diretório para abrir o projeto.
  2. Aguarde um pouco enquanto o Android Studio cria e sincroniza o projeto, conforme mostrado nos indicadores de atividade na parte inferior da janela.
  3. Como o SDK do Android ou as ferramentas de compilação não estão presentes, o Android Studio poderá encontrar alguns erros de compilação. Veja um exemplo a seguir. Siga as instruções no Android Studio para instalar/atualizar essas ferramentas e sincronizar o projeto. Se os problemas persistirem, siga o guia sobre como atualizar suas ferramentas com o SDK Manager.

6e026ae171f5b1eb.png

  1. Se necessário, siga estas etapas:
  • Instale todas as atualizações de plataforma e de plug-ins ou o FlutterRunConfigurationType.
  • Se o SDK do Dart ou do Flutter não estiver configurado, defina o caminho do SDK do Flutter para o plug-in do Flutter.
  • Configure os frameworks do Android.
  • Clique em "Get dependencies" ou "Run 'flutter packages get'".

Em seguida, reinicie o Android Studio.

53b7195f1c1deedb.png

be5ce477ba09225e.png 24810642cf859588.png

Verificar as dependências do projeto

O projeto precisa de uma dependência no pacote de animações. Provavelmente, o exemplo de código transferido por download já tem essa dependência listada, mas vamos conferir isso na configuração.

Navegue até o arquivo pubspec.yaml do módulo app e confira se a seção dependencies inclui uma dependência no pacote de animações:

animations: ^1.1.2

Executar o app inicial

  1. Confira se a configuração do build à esquerda da opção de dispositivo é app.
  2. Pressione o botão verde Run / Play para criar e executar o app.

a34cba7fab0a2af9.png

  1. Se você já tiver um device listado entre os dispositivos disponíveis no menu suspenso Flutter Device Selection, na parte superior da tela do editor, avance para a etapa 8. Caso contrário, clique em Create New Virtual Device.
  2. Na tela Select Hardware, selecione um smartphone, como o Pixel 3, e clique em Next.
  3. Na tela System Image, selecione uma versão recente do Android, de preferência o nível da API mais alto. Se ele não estiver instalado, clique no link Download exibido e faça o download.
  4. Clique em Next.
  5. Na tela Android Virtual Device (AVD), mantenha as configurações como estão e clique em Finish.
  6. Selecione um dispositivo (por exemplo, iPhone SE ou SDK do Android criado para <versão>) no menu suspenso Flutter Device Selection.
  7. Pressione o ícone Play (b8c998094aa23ac2.png).
  8. O Android Studio cria o app, faz a implantação e o abre automaticamente no dispositivo de destino.

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

Android

iOS

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.

Android

iOS

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.

Android

iOS

Vamos dar uma olhada no 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})
     : 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);
 }
}

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

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

Android

iOS

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

Agora, vamos usar o novo wrapper. Dentro da definição da classe MailPreviewCard, envolveremos o widget return 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(
     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,
       ),
     ),
   ),
 );
}

Não se esqueça de remover o InkWell do widget, porque a lógica dele agora está dentro da classe _OpenContainerWrapper. Também podemos remover o widget Material, já que as propriedades de cor do OpenContainer definem a cor do contêiner abrangido:

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

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

Android

iOS

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

Android

iOS

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

No home.dart, adicione o seguinte snippet à definição da classe _ReplyFabState, importando package:animations/animations.dart na parte superior do arquivo. Envolva o widget de retorno da função build() de definição da classe _ReplyFabState com um 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,
           ),
         ),
       ),
     ),
   );
 },
);

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.

No home.dart, dentro da definição da classe _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,
         ),
       ),
     ),
   );
 },
);

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

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

Android

iOS

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

Android

iOS

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

Agora, usaremos o novo SharedAxisTransitionPageWrapper para criar a transição desejada. Envolveremos as telas do widget com o wrapper para que ele retorne uma rota de página para o navegador com a transição desejada. Dentro da definição da classe ReplyRouterDelegate na propriedade pages, use o novo wrapper, em vez de envolver a tela de pesquisa com uma 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(
     pageBuilder: (context, animation, secondaryAnimation) {
       return const HomePage();
     },
   ),
   if (routePath is ReplySearchPath)
     const SharedAxisTransitionPageWrapper(
       transitionKey: ValueKey('search'),
       screen: const SearchPage(),
     ),
 ],
);

Agora, execute o app novamente.

Android

iOS

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.

Para corrigir as transições da página inicial, envolva a HomePage com o SharedAxisTransitionWrapper no 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(),
     ),
 ],
);

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

Android

iOS

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

Android

iOS

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

Assim como fizemos na etapa anterior, vamos usar o novo FadeThroughTransitionPageWrapper para criar a transição desejada. Envolveremos a tela da caixa de e-mails com o wrapper para que ele possa retornar uma rota de página para o navegador com a transição de esmaecimento cruzado. Dentro da definição da classe MailViewRouterDelegate na propriedade pages, use o novo wrapper, em vez de envolver a tela da caixa de e-mails com uma CustomTransitionPage:

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

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

Android

iOS

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

Android

iOS

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.

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

Agora, no _ReplyFabState, procure o widget fabSwitcher. O fabSwitcher permite que o FAB mude com base no contexto. O fabSwitcher confere se estamos em uma visualização de e-mail e, nesse caso, fornece um ícone diferente para o FAB.

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.

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

Android

iOS

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

Android

iOS

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

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

Android

iOS

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

Android

iOS

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

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

Android

iOS

Usando menos de 100 linhas de código Dart, o pacote de animações ajudou você a criar transições bonitas em um app existente 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.

Android

iOS

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

52f7119a30bb8f5c.png

dd11628e4c0f3fd3.png