Animações no Flutter

1. Introdução

As animações são uma ótima maneira de melhorar a experiência do usuário no app, comunicar informações importantes e tornar o app mais refinado e agradável de usar.

Visão geral do framework de animação do Flutter

O Flutter mostra efeitos de animação reconstruindo uma parte da árvore de widgets em cada frame. Ele fornece efeitos de animação pré-criados e outras APIs para facilitar a criação e a composição de animações.

  • As animações implícitas são efeitos de animação pré-criados que executam a animação inteira automaticamente. Quando o valor target da animação muda, ela é executada do valor atual para o valor de destino, e cada valor intermediário é exibido para que o widget seja animado sem problemas. Exemplos de animações implícitas incluem AnimatedSize, AnimatedScale e AnimatedPositioned.
  • As animações explícitas também são efeitos de animação pré-criados, mas exigem um objeto Animation para funcionar. Por exemplo, SizeTransition, ScaleTransition ou PositionedTransition.
  • Animation é uma classe que representa uma animação em execução ou interrompida e é composta por um valor que representa o valor de destino para o qual a animação está sendo executada e o status, que representa o valor atual que a animação está mostrando na tela a qualquer momento. É uma subclasse de Listenable e notifica os listeners quando o status muda enquanto a animação está em execução.
  • O AnimationController é uma maneira de criar uma animação e controlar o estado dela. Os métodos dela, como forward(), reset(), stop() e repeat(), podem ser usados para controlar a animação sem precisar definir o efeito que está sendo exibido, como escala, tamanho ou posição.
  • Os tweens são usados para interpolar valores entre um valor inicial e um final e podem representar qualquer tipo, como um double, Offset ou Color.
  • As curvas são usadas para ajustar a taxa de mudança de um parâmetro ao longo do tempo. Quando uma animação é executada, é comum aplicar uma curva de aceleração para tornar a taxa de mudança mais rápida ou mais lenta no início ou no fim da animação. As curvas recebem um valor de entrada entre 0,0 e 1,0 e retornam um valor de saída entre 0,0 e 1,0.

O que você vai criar

Neste codelab, você vai criar um jogo de perguntas e respostas de múltipla escolha com vários efeitos e técnicas de animação.

3026390ad413769c.gif

Você vai aprender a...

  • Criar um widget que anima o tamanho e a cor
  • Criar um efeito de virada de card 3D
  • Usar efeitos de animação sofisticados e pré-criados do pacote de animações
  • Adicionar suporte ao gesto de volta preditivo disponível na versão mais recente do Android

O que você vai aprender

Neste codelab, você vai aprender a:

  • Como usar efeitos animados implícitos para criar animações incríveis sem precisar de muito código.
  • Como usar efeitos animados explícitos para configurar seus próprios efeitos usando widgets animados pré-criados, como AnimatedSwitcher ou um AnimationController.
  • Como usar AnimationController para definir seu próprio widget que mostra um efeito 3D.
  • Como usar o pacote animations para mostrar efeitos de animação sofisticados com configuração mínima.

O que é necessário

  • O SDK do Flutter
  • Um ambiente de desenvolvimento integrado, como VSCode ou Android Studio / IntelliJ

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 (recomendado para implementar a volta preditiva na etapa 7) ou iOS conectado ao computador e configurado para o modo de desenvolvedor.
  • O simulador do 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).
  • Um computador desktop 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 (link em inglês).

Verificar a instalação

Para verificar se o SDK do Flutter está configurado corretamente e se você tem pelo menos uma das plataformas de destino acima instaladas, use a ferramenta Flutter Doctor:

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. Executar o app inicial

Fazer o download do app inicial

Use git para clonar o app inicial do repositório flutter/samples no GitHub.

git clone https://github.com/flutter/codelabs.git
cd codelabs/animations/step_01/

Se preferir, baixe o código-fonte como um arquivo ZIP.

Executar o app

Para executar o app, use o comando flutter run e especifique um dispositivo de destino, como android, ios ou chrome. Para conferir a lista completa de plataformas compatíveis, consulte a página Plataformas compatíveis.

flutter run -d android

Você também pode executar e depurar o app usando o ambiente de desenvolvimento integrado de sua escolha. Consulte a documentação oficial do Flutter para mais informações.

Tour pelo código

O app inicial é um jogo de perguntas e respostas de múltipla escolha que consiste em duas telas seguindo o padrão de design model-view-view-model, ou MVVM. A QuestionScreen (visualização) usa a classe QuizViewModel (modelo de visualização) para fazer perguntas de múltipla escolha ao usuário da classe QuestionBank (modelo).

  • home_screen.dart: mostra uma tela inicial com um botão Novo jogo.
  • main.dart: configura o MaterialApp para usar o Material 3 e mostrar a tela inicial.
  • model.dart: define as classes principais usadas em todo o app.
  • question_screen.dart: mostra a interface do jogo de perguntas e respostas.
  • view_model.dart: armazena o estado e a lógica do jogo de perguntas e respostas, exibidos pelo QuestionScreen.

fbb1e1f7b6c91e21.png

O app ainda não é compatível com efeitos animados, exceto pela transição de visualização padrão exibida pela classe Navigator do Flutter quando o usuário pressiona o botão Novo jogo.

4. Usar efeitos de animação implícitos

As animações implícitas são uma ótima opção em muitas situações, já que não exigem nenhuma configuração especial. Nesta seção, você vai atualizar o widget StatusBar para que ele mostre um placar animado. Para encontrar efeitos de animação implícitos comuns, navegue pela documentação da API ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Criar o widget de placar sem animação

Crie um arquivo lib/scoreboard.dart com o seguinte código:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color: score < i + 1
                  ? Colors.grey.shade400
                  : Colors.yellow.shade700,
            ),
        ],
      ),
    );
  }
}

Em seguida, adicione o widget Scoreboard aos filhos do widget StatusBar, substituindo os widgets Text que mostravam a pontuação e a contagem total de perguntas. O editor adiciona automaticamente o import "scoreboard.dart" necessário na parte de cima do arquivo.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

Esse widget mostra um ícone de estrela para cada pergunta. Quando uma pergunta é respondida corretamente, outra estrela acende instantaneamente, sem animação. Nas etapas a seguir, você vai ajudar a informar ao usuário que a pontuação mudou animando o tamanho e a cor dela.

Usar um efeito de animação implícito

Crie um widget chamado AnimatedStar que usa um widget AnimatedScale para mudar o valor de scale de 0.5 para 1.0 quando a estrela for ativada:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

Agora, quando o usuário responde a uma pergunta corretamente, o widget AnimatedStar atualiza o tamanho usando uma animação implícita. O color do Icon não é animado aqui, apenas o scale, que é feito pelo widget AnimatedScale.

84aec4776e70b870.gif

Usar um Tween para interpolar entre dois valores

Observe que a cor do widget AnimatedStar muda imediatamente depois que o campo isActive muda para "true".

Para conseguir um efeito de cor animado, tente usar um widget AnimatedContainer (que é outra subclasse de ImplicitlyAnimatedWidget), porque ele pode animar automaticamente todos os atributos, incluindo a cor. Infelizmente, nosso widget precisa mostrar um ícone, não um contêiner.

Você também pode tentar AnimatedIcon, que implementa efeitos de transição entre as formas dos ícones. No entanto, não há uma implementação padrão de um ícone de estrela na classe AnimatedIcons.

Em vez disso, vamos usar outra subclasse de ImplicitlyAnimatedWidget chamada TweenAnimationBuilder, que usa um Tween como parâmetro. Um tween é uma classe que usa dois valores (begin e end) e calcula os valores intermediários para que uma animação possa mostrá-los. Neste exemplo, vamos usar um ColorTween, que atende à interface Tween necessária para criar nosso efeito de animação.

Selecione o widget Icon e use a ação rápida "Wrap with Builder" no seu ambiente de desenvolvimento integrado (IDE, na sigla em inglês). Mude o nome para TweenAnimationBuilder. Em seguida, informe a duração e ColorTween.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(Icons.star, size: 50, color: value);     // And modify this line.
        },
      ),
    );
  }
}

Agora, faça uma recarga automática do app para conferir a nova animação.

8b0911f4af299a60.gif

Observe que o valor end do nosso ColorTween muda com base no valor do parâmetro isActive. Isso ocorre porque o TweenAnimationBuilder executa a animação novamente sempre que o valor Tween.end muda. Quando isso acontece, a nova animação é executada do valor atual para o novo valor final, o que permite mudar a cor a qualquer momento (mesmo enquanto a animação está em execução) e mostrar um efeito de animação suave com os valores intermediários corretos.

Aplicar uma curva

Ambos os efeitos de animação são executados a uma taxa constante, mas geralmente são mais interessantes e informativos quando aceleram ou diminuem a velocidade.

Uma Curve aplica uma função de suavização, que define a taxa de mudança de um parâmetro ao longo do tempo. O Flutter vem com uma coleção de curvas de aceleração pré-criadas na classe Curves, como easeIn ou easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Esses diagramas (disponíveis na página de documentação da API Curves) dão uma ideia de como as curvas funcionam. As curvas convertem um valor de entrada entre 0,0 e 1,0 (mostrado no eixo x) em um valor de saída entre 0,0 e 1,0 (mostrado no eixo y). Esses diagramas também mostram uma prévia de como vários efeitos de animação ficam quando usam uma curva de aceleração.

Crie um novo campo em AnimatedStar chamado _curve e transmita-o como um parâmetro para os widgets AnimatedScale e TweenAnimationBuilder.

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(Icons.star, size: 50, color: value);
        },
      ),
    );
  }
}

Neste exemplo, a curva elasticOut oferece um efeito de mola exagerado que começa com um movimento de mola e se equilibra no final.

8f84142bff312373.gif

Faça uma recarga automática do app para ver essa curva aplicada a AnimatedSize e TweenAnimationBuilder.

206dd8d9c1fae95.gif

Usar o DevTools para ativar animações lentas

Para depurar qualquer efeito de animação, o Flutter DevTools oferece uma maneira de desacelerar todas as animações no seu app para que você possa vê-las com mais clareza.

Para abrir o DevTools, verifique se o app está sendo executado no modo de depuração e abra o Widget Inspector selecionando-o na barra de ferramentas de depuração no VSCode ou clicando no botão Open Flutter DevTools na janela de ferramentas de depuração no IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Quando o inspetor de widgets estiver aberto, clique no botão Animações lentas na barra de ferramentas.

adea0a16d01127ad.png

5. Usar efeitos de animação explícitos

Assim como as animações implícitas, as explícitas são efeitos de animação pré-criados, mas, em vez de usar um valor de destino, elas usam um objeto Animation como parâmetro. Isso os torna úteis em situações em que a animação já está definida por uma transição de navegação, como AnimatedSwitcher ou AnimationController.

Usar um efeito de animação explícito

Para começar a usar um efeito de animação explícito, envolva o widget Card com um AnimatedSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

O AnimatedSwitcher usa um efeito de transição gradual por padrão, mas é possível substituir isso usando o parâmetro transitionBuilder. O builder de transição fornece o widget filho transmitido ao AnimatedSwitcher e um objeto Animation. Essa é uma ótima oportunidade para usar uma animação explícita.

Neste codelab, a primeira animação explícita que vamos usar é SlideTransition, que usa um Animation<Offset> que define o deslocamento inicial e final entre os widgets de entrada e saída.

As animações tween têm uma função auxiliar, animate(), que converte qualquer Animation em outro Animation com a animação tween aplicada. Isso significa que um Tween pode ser usado para converter o Animation fornecido pelo AnimatedSwitcher em um Animation, que será fornecido ao widget SlideTransition.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation = CurveTween(
          curve: Curves.easeInCubic,
        ).animate(animation);
        final offsetAnimation = Tween<Offset>(
          begin: Offset(-0.1, 0.0),
          end: Offset.zero,
        ).animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Isso usa Tween.animate para aplicar um Curve ao Animation e, em seguida, converter de um Tween que varia de 0,0 a 1,0 para um Tween que faz a transição de -0,1 a 0,0 no eixo x.

Outra opção é usar a função drive() da classe Animation, que usa qualquer Tween (ou Animatable) e o converte em um novo Animation. Isso permite que as animações sejam "encadeadas", tornando o código resultante mais conciso:

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

Outra vantagem de usar animações explícitas é que elas podem ser compostas juntas. Adicione outra animação explícita, FadeTransition, que usa a mesma animação curva envolvendo o widget SlideTransition.

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation = CurveTween(
      curve: Curves.easeInCubic,
    ).animate(animation);
    final offsetAnimation = Tween<Offset>(
      begin: Offset(-0.1, 0.0),
      end: Offset.zero,
    ).animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

Personalizar o layoutBuilder

Talvez você note um pequeno problema com o AnimationSwitcher. Quando um QuestionCard muda para uma nova pergunta, ele a apresenta no centro do espaço disponível enquanto a animação está em execução. No entanto, quando a animação é interrompida, o widget se encaixa na parte de cima da tela. Isso causa uma animação instável porque a posição final do card de pergunta não corresponde à posição enquanto a animação está sendo executada.

d77de181bdde58f7.gif

Para corrigir isso, o AnimatedSwitcher também tem um parâmetro layoutBuilder, que pode ser usado para definir o layout. Use esta função para configurar o criador de layout e alinhar o card à parte de cima da tela:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

Esse código é uma versão modificada do defaultLayoutBuilder da classe AnimatedSwitcher, mas usa Alignment.topCenter em vez de Alignment.center.

Resumo

  • Animações explícitas são efeitos de animação que usam um objeto Animation (em contraste com ImplicitlyAnimatedWidgets, que usam um destino value e duration).
  • A classe Animation representa uma animação em execução, mas não define um efeito específico.
  • Use Tween().animate ou Animation.drive() para aplicar Tweens e Curves (usando CurveTween) a uma animação.
  • Use o parâmetro layoutBuilder do AnimatedSwitcher para ajustar como ele organiza os filhos.

6. Controlar o estado de uma animação

Até agora, todas as animações foram executadas automaticamente pela estrutura. As animações implícitas são executadas automaticamente, e os efeitos de animação explícitos exigem um Animation para funcionar corretamente. Nesta seção, você vai aprender a criar seus próprios objetos Animation usando um AnimationController e usar um TweenSequence para combinar Tweens.

Executar uma animação usando um AnimationController

Para criar uma animação usando um AnimationController, siga estas etapas:

  1. Criar um StatefulWidget
  2. Use o mixin SingleTickerProviderStateMixin na sua classe State para fornecer um Ticker ao seu AnimationController
  3. Inicialize o AnimationController no método de ciclo de vida initState, fornecendo o objeto State atual ao parâmetro vsync (TickerProvider).
  4. Verifique se o widget é recriado sempre que o AnimationController notifica os listeners, usando AnimatedBuilder ou chamando listen() e setState manualmente.

Crie um arquivo flip_effect.dart e copie e cole o seguinte código:

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                    ? _previousChild
                    : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

Essa classe configura um AnimationController e executa a animação novamente sempre que o framework chama didUpdateWidget para notificar que a configuração do widget mudou e que pode haver um novo widget filho.

O AnimatedBuilder garante que a árvore de widgets seja reconstruída sempre que o AnimationController notifica os listeners, e o widget Transform é usado para aplicar um efeito de rotação 3D e simular um cartão sendo virado.

Para usar esse widget, envolva cada card de resposta com um widget CardFlipEffect. Não se esqueça de fornecer um key ao widget Card:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

Agora, faça uma recarga dinâmica do app para ver os cards de resposta virarem usando o widget CardFlipEffect.

5455def725b866f6.gif

Você vai notar que essa classe é muito parecida com um efeito de animação explícito. Na verdade, geralmente é uma boa ideia estender a classe AnimatedWidget diretamente para implementar sua própria versão. Infelizmente, como essa classe precisa armazenar o widget anterior no State, ela precisa usar um StatefulWidget. Para saber mais sobre como criar seus próprios efeitos de animação explícitos, consulte a documentação da API para AnimatedWidget.

Adicionar um atraso usando TweenSequence

Nesta seção, você vai adicionar um atraso ao widget CardFlipEffect para que cada card vire um de cada vez. Para começar, adicione um novo campo chamado delayAmount.

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

Em seguida, adicione a delayAmount ao método de build AnswerCards.

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

Em seguida, em _CardFlipEffectState, crie um novo Animation que aplique o atraso usando um TweenSequence. Isso não usa utilitários da biblioteca dart:async, como Future.delayed. Isso ocorre porque o atraso faz parte da animação e não é algo que o widget controla explicitamente quando usa o AnimationController. Isso facilita a depuração do efeito de animação ao ativar animações lentas no DevTools, já que ele usa o mesmo TickerProvider.

Para usar um TweenSequence, crie dois TweenSequenceItems, um contendo um ConstantTween que mantém a animação em 0 por uma duração relativa e um Tween regular que vai de 0.0 a 1.0.

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay;            // NEW

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration * (widget.delayAmount + 1),
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

Por fim, substitua a animação do AnimationController pela nova animação atrasada no método build.

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

Agora, faça uma recarga automática do app e veja os cards virarem um por um. Para um desafio, tente mudar a perspectiva do efeito 3D fornecido pelo widget Transform.

28b5291de9b3f55f.gif

7. Usar transições de navegação personalizadas

Até agora, vimos como personalizar efeitos em uma única tela, mas outra maneira de usar animações é fazer transições entre telas. Nesta seção, você vai aprender a aplicar efeitos de animação às transições de tela usando efeitos de animação integrados e efeitos de animação sofisticados pré-criados fornecidos pelo pacote oficial animations em pub.dev.

Animar uma transição de navegação

A classe PageRouteBuilder é um Route que permite personalizar a animação de transição. Ele permite substituir o callback transitionBuilder, que fornece dois objetos de animação, representando a animação de entrada e saída executada pelo Navigator.

Para personalizar a animação de transição, substitua o MaterialPageRoute por um PageRouteBuilder e personalize a animação de transição quando o usuário navegar do HomeScreen para o QuestionScreen. Use um FadeTransition (um widget explicitamente animado) para fazer a nova tela aparecer em cima da anterior.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // Add from here...
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

O pacote de animações oferece efeitos de animação sofisticados pré-criados, como FadeThroughTransition. Importe o pacote de animações e substitua o FadeTransition pelo widget FadeThroughTransition:

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeThroughTransition(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

Personalizar a animação de volta preditiva

1c0558ffa3b76439.gif

A volta preditiva é um novo recurso do Android que permite ao usuário espiar por trás da rota ou do app atual para ver o que está por trás antes de navegar. A animação de espiada é impulsionada pela localização do dedo do usuário enquanto ele arrasta de volta pela tela.

O Flutter oferece suporte à volta preditiva do sistema ativando o recurso no nível do sistema quando o Flutter não tem rotas para abrir na pilha de navegação ou, em outras palavras, quando uma ação de voltar sairia do app. Essa animação é processada pelo sistema e não pelo próprio Flutter.

O Flutter também oferece suporte à volta preditiva ao navegar entre rotas em um app Flutter. Um PageTransitionsBuilder especial chamado PredictiveBackPageTransitionsBuilder detecta gestos de volta preditiva do sistema e impulsiona a transição de página com o progresso do gesto.

A volta preditiva só é compatível com o Android U e versões mais recentes, mas o Flutter vai voltar normalmente ao comportamento original do gesto de volta e ao ZoomPageTransitionBuilder. Leia nossa postagem do blog para saber mais, incluindo uma seção sobre como configurar no seu próprio app.

Na configuração ThemeData do seu app, configure o PageTransitionsTheme para usar PredictiveBack no Android e o efeito de transição de esmaecimento do pacote de animações em outras plataformas:

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

Agora você pode mudar o retorno de chamada Navigator.push() para um MaterialPageRoute.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(                                        // Add from here...
        builder: (context) {
          return const QuestionScreen();
        },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

Use FadeThroughTransition para mudar a pergunta atual

O widget AnimatedSwitcher fornece apenas um Animation no callback do builder. Para resolver isso, o pacote animations fornece um PageTransitionSwitcher.

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },                                                        // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Usar OpenContainer

77358e5776eb104c.png

O widget OpenContainer do pacote animations oferece um efeito de animação de transformação de contêiner que se expande para criar uma conexão visual entre dois widgets.

O widget retornado por closedBuilder é exibido inicialmente e se expande para o widget retornado por openBuilder quando o contêiner é tocado ou quando o callback openContainer é chamado.

Para conectar o callback openContainer ao modelo de visualização, adicione uma nova transmissão do viewModel ao widget QuestionCard e armazene um callback que será usado para mostrar a tela "Game Over":

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel = QuizViewModel(
    onGameOver: _handleGameOver,
  );
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                    ? () {
                        viewModel.getNextQuestion();
                      }
                    : null,
                child: const Text('Next'),
              ),
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

Adicione um novo widget, GameOverScreen:

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(automaticallyImplyLeading: false),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text('You Win!', style: Theme.of(context).textTheme.displayLarge),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

No widget QuestionCard, substitua o Card por um widget OpenContainer do pacote animations, adicionando dois novos campos para o viewModel e o callback de contêiner aberto:

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. Parabéns

Parabéns! Você adicionou efeitos de animação a um app Flutter e aprendeu sobre os principais componentes do sistema de animação do Flutter. Especificamente, você aprendeu a:

  • Como usar um ImplicitlyAnimatedWidget
  • Como usar um ExplicitlyAnimatedWidget
  • Como aplicar Curves e Tweens a uma animação
  • Como usar widgets de transição pré-criados, como AnimatedSwitcher ou PageRouteBuilder
  • Como usar efeitos de animação sofisticados pré-criados do pacote animations, como FadeThroughTransition e OpenContainer
  • Como personalizar a animação de transição padrão, incluindo a adição de suporte para volta preditiva no Android.

3026390ad413769c.gif

A seguir

Confira alguns destes codelabs:

Ou faça o download do app de exemplo de animações, que mostra várias técnicas de animação.

Leitura adicional

Você encontra mais recursos de animação em flutter.dev:

Ou confira estes artigos no Medium:

Documentos de referência