Animaciones en Flutter

1. Introducción

Las animaciones son una excelente manera de mejorar la experiencia del usuario de tu app, comunicarle información importante y hacer que tu app sea más refinada y agradable de usar.

Descripción general del framework de animación de Flutter

Flutter muestra efectos de animación recompilando una parte del árbol de widgets en cada fotograma. Proporciona efectos de animación prediseñados y otras APIs para facilitar la creación y composición de animaciones.

  • Las animaciones implícitas son efectos de animación precompilados que ejecutan toda la animación automáticamente. Cuando cambia el valor objetivo de la animación, esta se ejecuta desde el valor actual hasta el valor objetivo y muestra cada valor intermedio para que el widget se anime de forma fluida. Algunos ejemplos de animaciones implícitas son AnimatedSize, AnimatedScale y AnimatedPositioned.
  • Las animaciones explícitas también son efectos de animación precompilados, pero requieren un objeto Animation para funcionar. Los ejemplos incluyen SizeTransition, ScaleTransition o PositionedTransition.
  • Animation es una clase que representa una animación en ejecución o detenida, y se compone de un valor que representa el valor objetivo al que se ejecuta la animación y el estado, que representa el valor actual que la animación muestra en la pantalla en un momento determinado. Es una subclase de Listenable y notifica a sus objetos de escucha cuando cambia el estado mientras se ejecuta la animación.
  • AnimationController es una forma de crear una animación y controlar su estado. Sus métodos, como forward(), reset(), stop() y repeat(), se pueden usar para controlar la animación sin necesidad de definir el efecto de animación que se muestra, como la escala, el tamaño o la posición.
  • Los transiciones se usan para interpolar valores entre un valor inicial y uno final, y pueden representar cualquier tipo, como un número doble, Offset o Color.
  • Las curvas se usan para ajustar la tasa de cambio de un parámetro a lo largo del tiempo. Cuando se ejecuta una animación, es común aplicar una curva de aceleración para que la tasa de cambio sea más rápida o más lenta al principio o al final de la animación. Las curvas toman un valor de entrada entre 0.0 y 1.0 y muestran un valor de salida entre 0.0 y 1.0.

Qué compilarás

En este codelab, crearás un juego de cuestionario de opción múltiple que incluya varios efectos y técnicas de animación.

3026390ad413769c.gif

Verás cómo hacer lo siguiente:

  • Compila un widget que anime su tamaño y color
  • Crea un efecto de giro de tarjeta 3D
  • Usa efectos de animación prediseñados y elegantes del paquete de animaciones
  • Agrega compatibilidad con el gesto atrás predictivo disponible en la versión más reciente de Android

Qué aprenderás

En este codelab, aprenderás lo siguiente:

  • Cómo usar efectos animados de forma implícita para lograr animaciones de aspecto atractivo sin necesidad de escribir mucho código
  • Cómo usar efectos animados de forma explícita para configurar tus propios efectos con widgets animados precompilados, como AnimatedSwitcher o AnimationController
  • Cómo usar AnimationController para definir tu propio widget que muestre un efecto 3D
  • Cómo usar el paquete animations para mostrar efectos de animación sofisticados con una configuración mínima

Requisitos

  • El SDK de Flutter
  • Un IDE, como VSCode o Android Studio / IntelliJ

2. Configura tu entorno de desarrollo de Flutter

Para completar este lab, necesitas dos tipos de software: el SDK de Flutter y un editor.

Puedes ejecutar el codelab con cualquiera de estos dispositivos o modalidades:

  • Un dispositivo físico Android (recomendado para implementar el gesto atrás predictivo en el paso 7) o iOS conectado a tu computadora y configurado en el modo de desarrollador
  • El simulador de iOS (requiere la instalación de herramientas de Xcode).
  • Android Emulator (requiere configuración en Android Studio)
  • Un navegador (se requiere Chrome para la depuración)
  • Una computadora de escritorio con Windows, Linux o macOS Debes desarrollar contenido en la plataforma donde tengas pensado realizar la implementación. por lo tanto, si quieres desarrollar una app de escritorio para Windows, debes desarrollarla en ese SO a fin de obtener acceso a la cadena de compilación correcta; encuentra detalles sobre los requisitos específicos del sistema operativo en docs.flutter.dev/desktop).

Cómo verificar la instalación

Para verificar que el SDK de Flutter esté configurado correctamente y que tengas instalada al menos una de las plataformas de destino anteriores, usa la herramienta 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. Ejecuta la app de partida

Descarga la app de partida

Usa git para clonar la app de partida del repositorio flutter/samples en GitHub.

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

También puedes descargar el código fuente como un archivo .zip.

Ejecuta la app

Para ejecutar la app, usa el comando flutter run y especifica un dispositivo de destino, como android, ios o chrome. Para obtener una lista completa de las plataformas compatibles, consulta la página Plataformas compatibles.

$ flutter run -d android

También puedes ejecutar y depurar la app con el IDE que elijas. Consulta la documentación oficial de Flutter para obtener más información.

Explora el código

La app de partida es un juego de cuestionarios de opción múltiple que consta de dos pantallas que siguen el patrón de diseño modelo-vista-modelo-vista o MVVM. QuestionScreen (View) usa la clase QuizViewModel (View-Model) para hacerle al usuario preguntas de opción múltiple de la clase QuestionBank (Model).

  • home_screen.dart: Muestra una pantalla con el botón New Game.
  • main.dart: Configura MaterialApp para usar Material 3 y mostrar la pantalla principal.
  • model.dart: Define las clases principales que se usan en toda la app.
  • question_screen.dart: Muestra la IU del juego de trivia.
  • view_model.dart: Almacena el estado y la lógica del juego de cuestionario que muestra QuestionScreen.

fbb1e1f7b6c91e21.png

La app aún no admite ningún efecto animado, excepto la transición de vista predeterminada que muestra la clase Navigator de Flutter cuando el usuario presiona el botón New Game.

4. Usa efectos de animación implícitos

Las animaciones implícitas son una excelente opción en muchas situaciones, ya que no requieren ninguna configuración especial. En esta sección, actualizarás el widget StatusBar para que muestre un marcador animado. Para encontrar efectos de animación implícitos comunes, explora la documentación de la API de ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Crea el widget de tabla de clasificación sin animación

Crea un archivo nuevo, lib/scoreboard.dart, con el siguiente 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,
            )
        ],
      ),
    );
  }
}

Luego, agrega el widget Scoreboard en los elementos secundarios del widget StatusBar, reemplazando los widgets Text que anteriormente mostraban la puntuación y el recuento total de preguntas. El editor debería agregar automáticamente el import "scoreboard.dart" obligatorio en la parte superior del archivo.

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

Este widget muestra un ícono de estrella para cada pregunta. Cuando se responde correctamente una pregunta, se enciende otra estrella de inmediato sin ninguna animación. En los siguientes pasos, ayudarás a informar al usuario que su puntuación cambió animando su tamaño y color.

Cómo usar un efecto de animación implícito

Crea un widget nuevo llamado AnimatedStar que use un widget AnimatedScale para cambiar el importe scale de 0.5 a 1.0 cuando la estrella se active:

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.

Ahora, cuando el usuario responde una pregunta correctamente, el widget AnimatedStar actualiza su tamaño con una animación implícita. El color de Icon no está animado aquí, solo el scale, que realiza el widget AnimatedScale.

84aec4776e70b870.gif

Cómo usar un Tween para interpolar entre dos valores

Observa que el color del widget AnimatedStar cambia inmediatamente después de que el campo isActive cambia a verdadero.

Para lograr un efecto de color animado, puedes usar un widget AnimatedContainer (que es otra subclase de ImplicitlyAnimatedWidget), ya que puede animar automáticamente todos sus atributos, incluido el color. Lamentablemente, nuestro widget debe mostrar un ícono, no un contenedor.

También puedes probar AnimatedIcon, que implementa efectos de transición entre las formas de los íconos. Sin embargo, no hay una implementación predeterminada de un ícono de estrella en la clase AnimatedIcons.

En su lugar, usaremos otra subclase de ImplicitlyAnimatedWidget llamada TweenAnimationBuilder, que toma un Tween como parámetro. Un interpolación es una clase que toma dos valores (begin y end) y calcula los valores intermedios para que una animación pueda mostrarlos. En este ejemplo, usaremos un ColorTween, que satisface la interfaz Tween<Color> necesaria para compilar nuestro efecto de animación.

Selecciona el widget Icon y usa la acción rápida "Wrap with Builder" en tu IDE. Cambia el nombre a TweenAnimationBuilder. Luego, proporciona la duración y 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.
      ),
    );
  }
}

Ahora, vuelve a cargar la app en caliente para ver la nueva animación.

8b0911f4af299a60.gif

Ten en cuenta que el valor end de nuestro ColorTween cambia según el valor del parámetro isActive. Esto se debe a que TweenAnimationBuilder vuelve a ejecutar su animación cada vez que cambia el valor de Tween.end. Cuando esto sucede, la nueva animación se ejecuta desde el valor de animación actual hasta el nuevo valor final, lo que te permite cambiar el color en cualquier momento (incluso mientras se ejecuta la animación) y mostrar un efecto de animación suave con los valores intermedios correctos.

Aplica una curva

Ambos efectos de animación se ejecutan a una velocidad constante, pero las animaciones suelen ser más interesantes y informativas a nivel visual cuando se aceleran o ralentizan.

Un Curve aplica una función de suavización, que define la tasa de cambio de un parámetro a lo largo del tiempo. Flutter se envía con una colección de curvas de suavización precompiladas en la clase Curves, como easeIn o easeOut.

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Estos diagramas (disponibles en la página de documentación de la API de Curves) dan una idea de cómo funcionan las curvas. Las curvas convierten un valor de entrada entre 0.0 y 1.0 (que se muestra en el eje x) en un valor de salida entre 0.0 y 1.0 (que se muestra en el eje y). Estos diagramas también muestran una vista previa de cómo se ven varios efectos de animación cuando se usa una curva de suavización.

Crea un campo nuevo en AnimatedStar llamado _curve y pásalo como parámetro a los widgets AnimatedScale y 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,
          );
        },
      ),
    );
  }
}

En este ejemplo, la curva elasticOut proporciona un efecto de resorte exagerado que comienza con un movimiento de resorte y se equilibra hacia el final.

8f84142bff312373.gif

Vuelve a cargar la app en caliente para ver esta curva aplicada a AnimatedSize y TweenAnimationBuilder.

206dd8d9c1fae95.gif

Cómo usar DevTools para habilitar animaciones lentas

Para depurar cualquier efecto de animación, las herramientas para desarrolladores de Flutter proporcionan una forma de ralentizar todas las animaciones de tu app, de modo que puedas verlas con mayor claridad.

Para abrir DevTools, asegúrate de que la app se esté ejecutando en modo de depuración y abre el Inspector de widgets. Para ello, selecciónalo en la barra de herramientas de depuración de VSCode o selecciona el botón Open Flutter DevTools en la ventana de herramientas de depuración de IntelliJ o Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

Una vez que se abra el inspector de widgets, haz clic en el botón Animaciones lentas de la barra de herramientas.

adea0a16d01127ad.png

5. Usa efectos de animación explícitos

Al igual que las animaciones implícitas, las animaciones explícitas son efectos de animación precompilados, pero, en lugar de tomar un valor objetivo, toman un objeto Animation como parámetro. Esto los hace útiles en situaciones en las que la animación ya está definida por una transición de navegación, AnimatedSwitcher o AnimationController, por ejemplo.

Cómo usar un efecto de animación explícito

Para comenzar con un efecto de animación explícito, une el widget Card con un 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
    );
  }
}

AnimatedSwitcher usa un efecto de fundido cruzado de forma predeterminada, pero puedes anularlo con el parámetro transitionBuilder. El compilador de transición proporciona el widget secundario que se pasó a AnimatedSwitcher y un objeto Animation. Esta es una gran oportunidad para usar una animación explícita.

En este codelab, la primera animación explícita que usaremos es SlideTransition, que toma un Animation<Offset> que define el desplazamiento inicial y final entre los que se moverán los widgets entrantes y salientes.

Los Tweens tienen una función auxiliar, animate(), que convierte cualquier Animation en otro Animation con el Tween aplicado. Esto significa que se puede usar un Tween<Offset> para convertir el Animation<double> que proporciona AnimatedSwitcher en un Animation<Offset>, que se proporcionará al 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,
          ),
        ),
      ),
    );
  }
}

Ten en cuenta que esto usa Tween.animate para aplicar un Curve a Animation y, luego, convertirlo de un Tween<double> que varía de 0.0 a 1.0 a un Tween<Offset> que pasa de -0.1 a 0.0 en el eje x.

Como alternativa, la clase Animation tiene una función drive() que toma cualquier Tween (o Animatable) y lo convierte en un Animation nuevo. Esto permite que los interpolación se “encadenen”, lo que hace que el código resultante sea más 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);
},

Otra ventaja de usar animaciones explícitas es que se pueden componer fácilmente. Agrega otra animación explícita, FadeTransition, que usa la misma animación curva uniendo el 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
  },

Cómo personalizar layoutBuilder

Es posible que notes un pequeño problema con AnimationSwitcher. Cuando una tarjeta de pregunta cambia a una pregunta nueva, la coloca en el centro del espacio disponible mientras se ejecuta la animación, pero cuando se detiene, el widget se ajusta a la parte superior de la pantalla. Esto causa una animación inestable porque la posición final de la tarjeta de pregunta no coincide con la posición mientras se ejecuta la animación.

d77de181bdde58f7.gif

Para solucionar este problema, AnimatedSwitcher también tiene un parámetro layoutBuilder, que se puede usar para definir el diseño. Usa esta función para configurar el compilador de diseño de modo que alinee la tarjeta con la parte superior de la pantalla:

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

Este código es una versión modificada de defaultLayoutBuilder de la clase AnimatedSwitcher, pero usa Alignment.topCenter en lugar de Alignment.center.

Resumen

  • Las animaciones explícitas son efectos de animación que toman un objeto Animation (en contraste con ImplicitlyAnimatedWidgets, que toman un valor y una duración objetivo).
  • La clase Animation representa una animación en ejecución, pero no define un efecto específico.
  • Usa Tween().animate o Animation.drive() para aplicar Tweens y Curves (con CurveTween) a una animación.
  • Usa el parámetro layoutBuilder de AnimatedSwitcher para ajustar la forma en que se organizan sus elementos secundarios.

6. Cómo controlar el estado de una animación

Hasta ahora, el framework ejecutó automáticamente todas las animaciones. Las animaciones implícitas se ejecutan automáticamente, y los efectos de animación explícitos requieren una animación para funcionar correctamente. En esta sección, aprenderás a crear tus propios objetos de animación con un AnimationController y a usar una TweenSequence para combinar Tweens.

Cómo ejecutar una animación con un AnimationController

Para crear una animación con un AnimationController, deberás seguir estos pasos:

  1. Crea un StatefulWidget
  2. Usa el mixin SingleTickerProviderStateMixin en tu clase de estado para proporcionar un Ticker a tu AnimationController.
  3. Inicializa AnimationController en el método de ciclo de vida initState y proporciona el objeto State actual al parámetro vsync (TickerProvider).
  4. Asegúrate de que tu widget se vuelva a compilar cada vez que AnimationController notifique a sus objetos de escucha, ya sea con AnimatedBuilder o llamando a listen() y setState de forma manual.

Crea un archivo nuevo, flip_effect.dart, y copia y pega el siguiente 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,
    );
  }
}

Esta clase configura un AnimationController y vuelve a ejecutar la animación cada vez que el framework llama a didUpdateWidget para notificarle que cambió la configuración del widget y que puede haber un widget secundario nuevo.

AnimatedBuilder se asegura de que el árbol de widgets se vuelva a compilar cada vez que AnimationController notifica a sus objetos de escucha, y el widget Transform se usa para aplicar un efecto de rotación en 3D para simular que se voltea una tarjeta.

Para usar este widget, une cada tarjeta de respuesta con un widget CardFlipEffect. Asegúrate de proporcionar un key al widget de tarjeta:

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

Ahora vuelve a cargar la app en caliente para ver cómo se voltean las tarjetas de respuesta con el widget CardFlipEffect.

5455def725b866f6.gif

Es posible que notes que esta clase se parece mucho a un efecto de animación explícito. De hecho, a menudo es una buena idea extender la clase AnimatedWidget directamente para implementar tu propia versión. Lamentablemente, como esta clase necesita almacenar el widget anterior en su estado, debe usar un StatefulWidget. Para obtener más información sobre cómo crear tus propios efectos de animación explícitos, consulta la documentación de la API de AnimatedWidget.

Cómo agregar un retraso con TweenSequence

En esta sección, agregarás un retraso al widget CardFlipEffect para que cada tarjeta se voltee de a una a la vez. Para comenzar, agrega un campo nuevo llamado 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();
}

Luego, agrega delayAmount al método de compilación 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]),

Luego, en _CardFlipEffectState, crea una nueva animación que aplique la demora con un TweenSequence. Ten en cuenta que esto no usa ninguna utilidad de la biblioteca dart:async, como Future.delayed. Esto se debe a que la demora es parte de la animación y no es algo que el widget controle de forma explícita cuando usa AnimationController. Esto facilita la depuración del efecto de animación cuando se habilitan animaciones lentas en DevTools, ya que usa el mismo TickerProvider.

Para usar un TweenSequence, crea dos TweenSequenceItem, uno que contenga un ConstantTween que mantenga la animación en 0 durante una duración relativa y un Tween normal que vaya 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 último, reemplaza la animación de AnimationController por la nueva animación retrasada en el método de compilación.

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

Ahora, vuelve a cargar la app en caliente y observa cómo se voltean las tarjetas una por una. Para un desafío, intenta experimentar con cambiar la perspectiva del efecto 3D que proporciona el widget Transform.

28b5291de9b3f55f.gif

7. Cómo usar transiciones de navegación personalizadas

Hasta ahora, vimos cómo personalizar los efectos en una sola pantalla, pero otra forma de usar las animaciones es para hacer la transición entre pantallas. En esta sección, aprenderás a aplicar efectos de animación a las transiciones de pantalla con efectos de animación integrados y efectos de animación prediseñados que proporciona el paquete oficial de animaciones en pub.dev.

Cómo animar una transición de navegación

La clase PageRouteBuilder es un Route que te permite personalizar la animación de transición. Te permite anular su devolución de llamada transitionBuilder, que proporciona dos objetos Animation, que representan la animación entrante y saliente que ejecuta el navegador.

Para personalizar la animación de transición, reemplaza MaterialPageRoute por PageRouteBuilder y para personalizar la animación de transición cuando el usuario navega de HomeScreen a QuestionScreen. Usa una FadeTransition (un widget animado de forma explícita) para que la pantalla nueva se desvanezca sobre la pantalla 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'),
),

El paquete de animaciones proporciona efectos de animación prediseñados, como FadeThroughTransition. Importa el paquete de animaciones y reemplaza FadeTransition por el 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'),
),

Cómo personalizar la animación de atrás predictivo

1c0558ffa3b76439.gif

El gesto atrás predictivo es una nueva función de Android que permite al usuario ver detrás de la ruta o la app actuales para ver qué hay antes de navegar. La animación de vista previa se controla según la ubicación del dedo del usuario mientras lo arrastra hacia atrás en la pantalla.

Flutter admite el gesto atrás predictivo del sistema habilitando la función a nivel del sistema cuando Flutter no tiene rutas para mostrar en su pila de navegación o, en otras palabras, cuando un gesto atrás saldría de la app. El sistema controla esta animación, no Flutter.

Flutter también admite el gesto atrás predictivo cuando se navega entre rutas dentro de una app de Flutter. Un PageTransitionsBuilder especial llamado PredictiveBackPageTransitionsBuilder detecta los gestos atrás predictivos del sistema y controla su transición de página con el progreso del gesto.

El gesto atrás predictivo solo es compatible con Android U y versiones posteriores, pero Flutter recurrirá de forma fluida al comportamiento original del gesto atrás y a ZoomPageTransitionBuilder. Consulta nuestra entrada de blog para obtener más información, incluida una sección sobre cómo configurarlo en tu propia app.

En la configuración de ThemeData de tu app, configura PageTransitionsTheme para usar PredictiveBack en Android y el efecto de transición de atenuación del paquete de animaciones en otras 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(),
    );
  }
}

Ahora puedes cambiar la devolución de llamada de Navigator.push() a una 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'),
),

Cómo usar FadeThroughTransition para cambiar la pregunta actual

El widget AnimatedSwitcher solo proporciona una animación en su devolución de llamada del compilador. Para abordar este problema, el paquete animations proporciona un 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,
          ),
        ),
      ),
    );
  }
}

Usa OpenContainer

77358e5776eb104c.png

El widget OpenContainer del paquete animations proporciona un efecto de animación de transformación de contenedor que se expande para crear una conexión visual entre dos widgets.

El widget que muestra closedBuilder se muestra inicialmente y se expande al widget que muestra openBuilder cuando se presiona el contenedor o cuando se llama a la devolución de llamada de openContainer.

Para conectar la devolución de llamada de openContainer al modelo de vista, agrega un nuevo pase del modelo de vista al widget QuestionCard y almacena una devolución de llamada que se usará para mostrar la pantalla "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
}

Agrega un widget nuevo, 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);
              },
            ),
          ],
        ),
      ),
    );
  }
}

En el widget QuestionCard, reemplaza la tarjeta por un widget OpenContainer del paquete de animaciones y agrega dos campos nuevos para la devolución de llamada de viewModel y Open Container:

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

¡Felicitaciones! Agregaste correctamente efectos de animación a una app de Flutter y aprendiste sobre los componentes principales del sistema de animación de Flutter. Específicamente, aprendiste lo siguiente:

  • Cómo usar un ImplicitlyAnimatedWidget
  • Cómo usar un ExplicitlyAnimatedWidget
  • Cómo aplicar curvas y interpolación a una animación
  • Cómo usar widgets de transición precompilados, como AnimatedSwitcher o PageRouteBuilder
  • Cómo usar efectos de animación precompilados sofisticados del paquete animations, como FadeThroughTransition y OpenContainer
  • Cómo personalizar la animación de transición predeterminada, incluida la compatibilidad con el gesto de atrás predictivo en Android

3026390ad413769c.gif

¿Qué sigue?

Consulta algunos de estos codelabs:

También puedes descargar la app de muestra de animaciones, que muestra varias técnicas de animación.

Lecturas adicionales

Puedes encontrar más recursos de animación en flutter.dev:

También puedes consultar estos artículos en Medium:

Documentos de referencia