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 deixar o app mais sofisticado 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. Ela oferece 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 toda a animação automaticamente. Quando o valor alvo da animação muda, ela é executada do valor atual para o valor de destino e exibe cada valor no meio 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á exibindo na tela a qualquer momento. Ela é uma subclasse de Listenable e notifica os listeners quando o status muda enquanto a animação está em execução.
  • AnimationController é uma maneira de criar uma animação e controlar o estado dela. Os métodos dele, como forward(), reset(), stop() e repeat(), podem ser usados para controlar a animação sem precisar definir o efeito que está sendo mostrado, como a escala, o tamanho ou a posição.
  • Os tweens são usados para interpolar valores entre um valor inicial e final e podem representar qualquer tipo, como 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 abrandamento para acelerar ou desacelerar a taxa de mudança 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 com várias opções de resposta que tem 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 cartão 3D
  • Usar efeitos de animação 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:

  • Como usar efeitos de animação implícita 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 predefinidos, como AnimatedSwitcher ou 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 uma configuração mínima.

O que é necessário

  • O SDK do Flutter
  • Um ambiente de desenvolvimento integrado, como o VSCode ou o 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 builds adequada. Há requisitos específicos de cada sistema operacional que são abordados em detalhes em docs.flutter.dev/desktop.

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 de início do repositório flutter/samples no GitHub.

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

Se preferir, faça o download do 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 preferência. Consulte a documentação oficial do Flutter para mais informações.

Tour pelo código

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

  • home_screen.dart: mostra uma tela com um botão New Game.
  • 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.
  • view_model.dart: armazena o estado e a lógica do jogo de perguntas, exibido pelo QuestionScreen.

fbb1e1f7b6c91e21.png

O app ainda não oferece suporte a efeitos animados, exceto a transição de visualização padrão exibida pela classe Navigator do Flutter quando o usuário pressiona o botão New Game.

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

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

206dd8d9c1fae95.gif

Criar o widget de placar não animado

Crie um novo 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 vai adicionar 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 nenhuma animação. Nas próximas etapas, você vai ajudar a informar ao usuário que a pontuação dele mudou, animando o tamanho e a cor.

Usar um efeito de animação implícita

Crie um novo widget chamado AnimatedStar que use um widget AnimatedScale para mudar o valor de scale de 0.5 para 1.0 quando a estrela ficar ativa:

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(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

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 usar AnimatedIcon, que implementa efeitos de transição entre as formas dos ícones. Mas 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 interpolador é uma classe que recebe 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<Color> necessária para criar o efeito de animação.

Selecione o widget Icon e use a ação rápida "Wrap with Builder" no seu ambiente de desenvolvimento integrado. Mude o nome para TweenAnimationBuilder. Em seguida, informe a duração e o 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,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

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

8b0911f4af299a60.gif

O valor end da 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 durante a execução da animação) e exibir um efeito de animação suave com os valores intermediários corretos.

Aplicar uma curva

Esses dois efeitos de animação são executados a uma taxa constante, mas as animações geralmente são mais interessantes e informativas quando aceleram ou diminuem a velocidade.

Um Curve aplica uma função de aceleraçã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 transiçã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 (exibido no eixo x) em um valor de saída entre 0,0 e 1,0 (exibido 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 transiçã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 fornece um efeito de mola exagerado que começa com um movimento de mola e se equilibra até o final.

8f84142bff312373.gif

Faça uma recarga automática do app para conferir 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 app para que você possa conferir a animação com mais clareza.

Para abrir o DevTools, verifique se o app está em execução no modo de depuração e abra o Widget Inspector selecionando-o na Debug toolbar no VSCode ou selecionando o botão Open Flutter DevTools na Debug tool window 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 animações explícitas são efeitos de animação predefinidos, mas, em vez de usar um valor de destino, elas usam um objeto Animation como parâmetro. Isso as torna úteis em situações em que a animação já é definida por uma transição de navegação, AnimatedSwitcher ou AnimationController, por exemplo.

Usar um efeito de animação explícito

Para começar com 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
    );
  }
}

Por padrão, AnimatedSwitcher usa um efeito de transição, mas você pode substituir isso usando o parâmetro transitionBuilder. O builder de transição fornece o widget filho que foi transmitido para o AnimatedSwitcher e um objeto Animation. Essa é uma ótima oportunidade para usar uma animação explícita.

Para este 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.

Os tweens têm uma função auxiliar, animate(), que converte qualquer Animation em outro Animation com o tween aplicado. Isso significa que um Tween<Offset> pode ser usado para converter o Animation<double> fornecido pelo AnimatedSwitcher em um Animation<Offset>, 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,
          ),
        ),
      ),
    );
  }
}

Observe que isso usa Tween.animate para aplicar um Curve ao Animation e, em seguida, convertê-lo de um Tween<double> que varia de 0,0 a 1,0 para um Tween<Offset> que faz a transição de -0,1 a 0,0 no eixo x.

Como alternativa, a classe Animation tem uma função drive() que recebe qualquer Tween (ou Animatable) e o converte em um novo Animation. Isso permite que as transiçõ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 facilmente compostas. Adicione outra animação explícita, FadeTransition, que usa a mesma animação curvada, 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 card de pergunta muda para uma nova pergunta, ele é exibido no centro do espaço disponível enquanto a animação está em execução. Quando a animação é interrompida, o widget é fixado 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á em execução.

d77de181bdde58f7.gif

Para corrigir isso, o AnimatedSwitcher também tem um parâmetro layoutBuilder, que pode ser usado para definir o layout. Use essa função para configurar o Layout Builder 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

  • As animações explícitas são efeitos de animação que usam um objeto Animation (ao contrário dos ImplicitlyAnimatedWidgets, que usam um valor e duração de destino)
  • 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 curvas (usando CurveTween) a uma animação.
  • Use o parâmetro layoutBuilder do AnimatedSwitcher para ajustar a disposição das filhas.

6. Controlar o estado de uma animação

Até agora, todas as animações foram executadas automaticamente pelo framework. As animações implícitas são executadas automaticamente, e os efeitos de animação explícitos exigem uma animação para funcionar corretamente. Nesta seção, você vai aprender a criar seus próprios objetos de animação usando um AnimationController e a usar uma 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 classe de estado para fornecer um Ticker ao AnimationController.
  3. Inicializar o AnimationController no método de ciclo de vida initState, fornecendo o objeto de estado atual ao parâmetro vsync (TickerProvider).
  4. Verifique se o widget é recriado sempre que o AnimationController notifica os listeners, usando o AnimatedBuilder ou chamando listen() e setState manualmente.

Crie um novo 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 recriada sempre que o AnimationController notificar os listeners. O widget Transform é usado para aplicar um efeito de rotação 3D para simular a virada de um cartão.

Para usar esse widget, envolva cada card de resposta com um CardFlipEffect. Forneça um key ao widget de 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 sendo virados usando o widget CardFlipEffect.

5455def725b866f6.gif

Essa classe se parece muito com um efeito de animação explícito. Na verdade, é uma boa ideia estender a classe AnimatedWidget diretamente para implementar sua própria versão. Como essa classe precisa armazenar o widget anterior no estado, ela precisa usar um StatefulWidget. Para saber mais sobre como criar seus próprios efeitos de animação explícita, 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 o 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 uma nova animação que aplique o atraso usando um TweenSequence. Não use 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 normal 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>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

Por fim, substitua a animação do AnimationController pela nova animação atrasada no método de 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 observe os cards sendo virados 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, aprendemos a personalizar efeitos em uma única tela, mas outra maneira de usar animações é para fazer a transição 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 pré-criados fornecidos pelo pacote oficial animations (link em inglês) no 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 que você substitua o callback transitionBuilder, que fornece dois objetos Animation, representando a animação de entrada e saída executada pelo Navigation.

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 da HomeScreen para a QuestionScreen. Use uma FadeTransition (um widget explicitamente animado) para fazer com que a nova tela apareça em cima da anterior.

lib/home_screen.dart

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

O pacote de animações oferece efeitos de animação predefinidos, como FadeThroughTransition. Importe o pacote de animações e substitua a 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(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  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 dar uma olhada atrás da rota ou do app atual para saber o que está por trás antes de navegar. A animação de peek é determinada pelo local do dedo do usuário enquanto ele é arrastado de volta pela tela.

O Flutter oferece suporte à volta preditiva do sistema ao ativar o recurso no nível do sistema quando o Flutter não tem rotas para destacar na pilha de navegação, ou seja, quando uma volta sai 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 direciona a transição de página com o progresso do gesto.

A volta preditiva só tem suporte no Android U e versões mais recentes, mas o Flutter voltará ao comportamento original do gesto de volta e ao ZoomPageTransitionBuilder. Confira nossa postagem do blog para saber mais, incluindo uma seção sobre como configurar no seu app.

Na configuração do ThemeData do app, configure o PageTransitionsTheme para usar o PredictiveBack no Android e o efeito de transição de desbotamento 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),
        useMaterial3: true,
        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 a chamada Navigator.push() para um MaterialPageRoute.

lib/home_screen.dart

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

Usar a transição de desbotamento para mudar a pergunta atual

O widget AnimatedSwitcher fornece apenas uma animação no callback do builder. Para resolver esse problema, 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(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      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 o 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 é mostrado 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 um novo elemento de visualização 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 de animações, adicionando dois novos campos para o callback do viewModel e do 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 componentes principais do sistema de animação do Flutter. Mais especificamente, você aprendeu:

  • Como usar um ImplicitlyAnimatedWidget
  • Como usar um ExplicitlyAnimatedWidget
  • Como aplicar curvas e interpolações a uma animação
  • Como usar widgets de transição pré-criados, como AnimatedSwitcher ou PageRouteBuilder
  • Como usar efeitos de animação 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 à volta preditiva no Android.

3026390ad413769c.gif

Qual é a próxima etapa?

Confira alguns desses codelabs:

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

Leia mais

Encontre mais recursos de animação em flutter.dev:

Ou confira estes artigos no Medium:

Documentos de referência