Animaciones en Flutter

1. Introducción

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

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

Flutter muestra efectos de animación reconstruyendo 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 prediseñados que ejecutan toda la animación automáticamente. Cuando cambia el valor target de la animación, se ejecuta la animación desde el valor actual hasta el valor objetivo y se muestra cada valor intermedio para que el widget se anime sin problemas. Algunos ejemplos de animaciones implícitas son AnimatedSize, AnimatedScale y AnimatedPositioned.
  • Las animaciones explícitas también son efectos de animación prediseñados, 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 dirige la animación y el estado, que representa el valor actual que la animación muestra en la pantalla en cualquier momento. Es una subclase de Listenable y notifica a sus listeners 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 tweens se usan para interpolar valores entre un valor inicial y un valor final, y pueden representar cualquier tipo, como un valor 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 devuelven un valor de salida entre 0.0 y 1.0.

Qué compilarás

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

3026390ad413769c.gif

Verás cómo hacer lo siguiente:

  • Crea un widget que anime su tamaño y color
  • Crea un efecto de giro de tarjeta en 3D
  • Usar efectos de animación prediseñados sofisticados del paquete de animaciones
  • Se agregó 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 los efectos animados de forma implícita para lograr animaciones atractivas sin necesidad de escribir mucho código
  • Cómo usar efectos animados de forma explícita para configurar tus propios efectos con widgets animados prediseñados, como AnimatedSwitcher o un 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 de 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; En docs.flutter.dev/desktop, puede encontrar detalles sobre los requisitos específicos del sistema operativo.

Verifica tu instalación

Para verificar que tu 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 inicio

Usa git para clonar la app de inicio desde el repositorio flutter/samples en GitHub.

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

También tienes la opción de descargar el código fuente como 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 la 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 prefieras. 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 preguntas de opción múltiple que consta de dos pantallas que siguen el patrón de diseño de modelo-vista-vista-modelo, o MVVM. La QuestionScreen (vista) usa la clase QuizViewModel (modelo de vistas) para hacerle al usuario preguntas de opción múltiple de la clase QuestionBank (modelo).

  • home_screen.dart: Muestra una pantalla con un 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 preguntas.
  • view_model.dart: Almacena el estado y la lógica del juego de preguntas, que muestra el QuestionScreen.

fbb1e1f7b6c91e21.png

Por el momento, la app 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. Cómo usar 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ícita comunes, explora la documentación de la API de ImplicitlyAnimatedWidget.

206dd8d9c1fae95.gif

Crea el widget del marcador 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 antes mostraban la puntuación y el recuento total de preguntas. El editor debería agregar automáticamente el import "scoreboard.dart" requerido 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 al instante 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 nuevo widget llamado AnimatedStar que use un widget AnimatedScale para cambiar la cantidad de 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(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

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

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

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

Ahora, cuando el usuario responde una pregunta correctamente, el widget AnimatedStar actualiza su tamaño con una animación implícita. El Icon de color no se anima aquí, solo el scale, que se realiza con 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 intentar 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 necesita 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 tween 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 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. Luego, 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);     // And modify this line.
        },
      ),
    );
  }
}

Ahora, recarga 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 fluido con los valores intermedios correctos.

Cómo aplicar una curva

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

Un Curve aplica una función de aceleración, que define la tasa de cambio de un parámetro a lo largo del tiempo. Flutter incluye una colección de curvas de aceleración prediseñadas 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) brindan 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 los diferentes efectos de animación cuando usan una curva de aceleració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

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

206dd8d9c1fae95.gif

Cómo usar Herramientas para desarrolladores para habilitar animaciones lentas

Para depurar cualquier efecto de animación, Flutter DevTools proporciona una forma de ralentizar todas las animaciones de tu app, de modo que puedas ver la animación 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 seleccionándolo en la barra de herramientas de depuración en VSCode o seleccionando el botón Abrir Flutter DevTools en la ventana de herramientas de depuración en 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 en 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 prediseñados, pero, en lugar de tomar un valor de destino, toman un objeto Animation como parámetro. Esto las 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.

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 excelente 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 para convertir el Animation proporcionado por el AnimatedSwitcher en un Animation, 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 al Animation y, luego, convertirlo de un Tween que varía de 0.0 a 1.0 a un Tween que realiza la transición 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 nuevo Animation. Esto permite que las interpolaciones 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 juntas. Agrega otra animación explícita, FadeTransition, que use la misma animación curva envolviendo 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
  },

Personaliza el diseño de layoutBuilder

Es posible que notes un pequeño problema con el AnimationSwitcher. Cuando un QuestionCard cambia a una pregunta nueva, la organiza en el centro del espacio disponible mientras se ejecuta la animación, pero cuando se detiene la animación, el widget se ajusta a la parte superior de la pantalla. Esto provoca 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, el 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 (a diferencia de ImplicitlyAnimatedWidgets, que toma un objetivo value y duration).
  • 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 organiza 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 un Animation para funcionar correctamente. En esta sección, aprenderás a crear tus propios objetos Animation con un AnimationController y a usar un TweenSequence para combinar objetos Tween.

Cómo ejecutar una animación con un AnimationController

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

  1. Cómo crear un StatefulWidget
  2. Usa el mixin SingleTickerProviderStateMixin en tu clase State para proporcionar un Ticker a tu AnimationController.
  3. Inicializa AnimationController en el método de ciclo de vida initState, proporcionando 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 listeners, 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 la configuración del widget cambió y que podría haber un nuevo widget secundario.

El AnimatedBuilder garantiza que el árbol de widgets se vuelva a compilar cada vez que el AnimationController notifica a sus listeners, y el widget Transform se usa para aplicar un efecto de rotación en 3D para simular que se da vuelta una tarjeta.

Para usar este widget, incluye cada tarjeta de respuesta en un widget CardFlipEffect. Asegúrate de proporcionar un key al widget Card:

lib/question_screen.dart

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

Ahora vuelve a cargar la app en caliente para ver cómo se dan vuelta 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, suele ser 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 State, 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 una demora al widget CardFlipEffect para que cada tarjeta se dé vuelta de a una. 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 un nuevo Animation que aplique la demora con un TweenSequence. Ten en cuenta que no usa ninguna utilidad de la biblioteca dart:async, como Future.delayed. Esto se debe a que la demora forma 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 Herramientas para desarrolladores, ya que usa el mismo TickerProvider.

Para usar un TweenSequence, crea dos TweenSequenceItems, 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>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

Por último, reemplaza la animación de AnimationController por la nueva animación retrasada en el método build.

lib/flip_effect.dart

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

Ahora, vuelve a cargar la app en caliente y mira cómo se dan vuelta las tarjetas una por una. Para un desafío, experimenta 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 sofisticados que proporciona el paquete oficial animations 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 Navigator.

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 un FadeTransition (un widget animado de forma explícita) para que la pantalla nueva aparezca con un efecto de atenuación sobre la pantalla anterior.

lib/home_screen.dart

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

El paquete de animaciones proporciona efectos de animación prediseñados sofisticados, 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(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  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 echar un vistazo detrás de la ruta o la app actual para ver qué hay detrás antes de navegar. La animación de vista previa se basa en la ubicación del dedo del usuario mientras lo arrastra hacia atrás por 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 descartar 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 de atrás predictivo cuando se navega entre rutas dentro de una app de Flutter. Un PageTransitionsBuilder especial llamado PredictiveBackPageTransitionsBuilder detecta los gestos de atrás predictivo del sistema y controla la transición de página con el progreso del gesto.

El gesto atrás predictivo solo se admite en Android U y versiones posteriores, pero Flutter recurrirá de forma correcta 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 que use PredictiveBack en Android y el efecto de transición de desvanecimiento 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),
        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 Navigator.push() a MaterialPageRoute.

lib/home_screen.dart

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

Usa FadeThroughTransition para cambiar la pregunta actual

El widget AnimatedSwitcher solo proporciona un Animation en su devolución de llamada del compilador. Para solucionar 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(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },                                                        // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Usa OpenContainer

77358e5776eb104c.png

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

El widget que devuelve closedBuilder se muestra inicialmente y se expande al widget que devuelve 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 openContainer al modelo de vista, agrega un nuevo paso viewModel 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 el Card por un widget OpenContainer del paquete animations y agrega dos campos nuevos para la devolución de llamada de viewModel y el contenedor abierto:

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 Curves y Tweens a una animación
  • Cómo usar widgets de transición prediseñados, como AnimatedSwitcher o PageRouteBuilder
  • Cómo usar efectos de animación prediseñados sofisticados del paquete animations, como FadeThroughTransition y OpenContainer
  • Cómo personalizar la animación de transición predeterminada, incluida la adición de compatibilidad con el gesto de atrás predictivo en Android

3026390ad413769c.gif

Próximos pasos

Consulta algunos de estos codelabs:

También puedes descargar la app de ejemplo 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