Saltar al contenido principal

Tutorial de animaciones

Un tutorial que muestra cómo construir animaciones explícitas en Flutter.

Este tutorial te muestra cómo construir animaciones explícitas en Flutter. Los ejemplos se construyen unos sobre otros, introduciéndote a diferentes aspectos de la biblioteca de animaciones. El tutorial se basa en conceptos, clases y métodos esenciales de la biblioteca de animaciones de los que puedes aprender en Introducción a las animaciones.

El SDK de Flutter también proporciona animaciones explícitas integradas, como FadeTransition, SizeTransition, y SlideTransition. Estas animaciones simples se activan estableciendo un punto inicial y un punto final. Son más simples de implementar que las animaciones explícitas personalizadas, que se describen aquí.

Las siguientes secciones te guían a través de varios ejemplos de animación. Cada sección proporciona un enlace al código fuente de ese ejemplo.

Renderizar animaciones

#

Hasta ahora has aprendido cómo generar una secuencia de números a lo largo del tiempo. Nada se ha renderizado en la pantalla. Para renderizar con un objeto Animation, almacena el objeto Animation como un miembro de tu widget, luego usa su valor para decidir cómo dibujar.

Considera la siguiente app que dibuja el logo de Flutter sin animación:

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

void main() => runApp(const LogoApp());

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

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: 300,
        width: 300,
        child: const FlutterLogo(),
      ),
    );
  }
}

App source: animate0

A continuación se muestra el mismo código modificado para animar el logo para que crezca desde cero hasta su tamaño completo. Al definir un AnimationController, debes pasar un objeto vsync. El parámetro vsync se describe en la sección de AnimationController.

Los cambios con respecto al ejemplo no animado están resaltados:

dart
class _LogoAppState extends State<LogoApp> {
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
      ..addListener(() {
        setState(() {
          // The state that has changed here is the animation object's value.
        });
      });
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: 300,
        width: 300,
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

App source: animate1

La función addListener() llama a setState(), por lo que cada vez que la Animation genera un nuevo número, el fotograma actual se marca como dirty, lo que fuerza a que build() sea llamado de nuevo. En build(), el contenedor cambia de tamaño porque su altura y ancho ahora usan animation.value en lugar de un valor hardcodeado. Desecha el controlador cuando el objeto State sea descartado para evitar fugas de memoria.

¡Con estos pocos cambios, has creado tu primera animación en Flutter!

Simplificando con AnimatedWidget

#

La clase base AnimatedWidget te permite separar el código del widget principal del código de animación. AnimatedWidget no necesita mantener un objeto State para contener la animación. Agrega la siguiente clase AnimatedLogo:

dart
class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({super.key, required Animation<double> animation})
    : super(listenable: animation);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
}

AnimatedLogo usa el valor actual de la animation al dibujarse a sí mismo.

LogoApp todavía gestiona el AnimationController y el Tween, y pasa el objeto Animation a AnimatedLogo:

dart
void main() => runApp(const LogoApp());

class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({super.key, required Animation<double> animation})
      : super(listenable: animation);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
}

class LogoApp extends StatefulWidget {
  // ...

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
      ..addListener(() {
        setState(() {
          // The state that has changed here is the animation object's value.
        });
      });
    animation = Tween<double>(begin: 0, end: 300).animate(controller);
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
  Widget build(BuildContext context) => AnimatedLogo(animation: animation);

  // ...
}

App source: animate2

Monitorear el progreso de la animación

#

A menudo es útil saber cuándo una animación cambia de estado, como finalizar, avanzarse o invertirse. Puedes recibir notificaciones de esto con addStatusListener(). El siguiente código modifica el ejemplo anterior para que escuche un cambio de estado e imprima una actualización. La línea resaltada muestra el cambio:

dart
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
      ..addStatusListener((status) => print('$status'));
    controller.forward();
  }
  // ...
}

Ejecutar este código produce esta salida:

AnimationStatus.forward
AnimationStatus.completed

A continuación, usa addStatusListener() para invertir la animación al principio o al final. Esto crea un efecto de "respiración":

dart
void initState() {
  super.initState();
  controller =
      AnimationController(duration: const Duration(seconds: 2), vsync: this);
  animation = Tween<double>(begin: 0, end: 300).animate(controller);
  animation = Tween<double>(begin: 0, end: 300).animate(controller)
    ..addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        controller.reverse();
      } else if (status == AnimationStatus.dismissed) {
        controller.forward();
      }
    })
    ..addStatusListener((status) => print('$status'));
  controller.forward();
}

App source: animate3

Refactorizar con AnimatedBuilder

#

Un problema con el código del ejemplo animate3, es que cambiar la animación requería cambiar el widget que renderiza el logo. Una mejor solución es separar las responsabilidades en diferentes clases:

  • Renderizar el logo
  • Definir el objeto Animation
  • Renderizar la transición

Puedes lograr esta separación con la ayuda de la clase AnimatedBuilder. Un AnimatedBuilder es una clase independiente en el árbol de renderizado. Al igual que AnimatedWidget, AnimatedBuilder escucha automáticamente las notificaciones del objeto Animation, y marca el árbol de widgets como dirty según sea necesario, por lo que no necesitas llamar a addListener().

El árbol de widgets para el ejemplo animate4 se ve así:

Árbol de widgets de AnimatedBuilder

Comenzando desde la parte inferior del árbol de widgets, el código para renderizar el logo es sencillo:

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

  // Leave out the height and width so it fills the animating parent.
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: const FlutterLogo(),
    );
  }
}

Los tres bloques centrales del diagrama se crean en el método build() en GrowTransition, mostrado a continuación. El widget GrowTransition en sí es stateless y contiene el conjunto de variables finales necesarias para definir la animación de transición. La función build() crea y devuelve el AnimatedBuilder, que toma el método (builder anónimo) y el objeto LogoWidget como parámetros. El trabajo de renderizar la transición realmente ocurre en el método (builder anónimo), que crea un Container del tamaño adecuado para forzar al LogoWidget a encogerse para ajustarse.

Un punto complicado en el código siguiente es que el child parece estar especificado dos veces. Lo que está sucediendo es que la referencia externa de child se pasa a AnimatedBuilder, que la pasa a la clausura anónima, que luego usa ese objeto como su child. El resultado neto es que el AnimatedBuilder se inserta entre los dos widgets en el árbol de renderizado.

dart
class GrowTransition extends StatelessWidget {
  const GrowTransition({
    required this.child,
    required this.animation,
    super.key,
  });

  final Widget child;
  final Animation<double> animation;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: animation,
        builder: (context, child) {
          return SizedBox(
            height: animation.value,
            width: animation.value,
            child: child,
          );
        },
        child: child,
      ),
    );
  }
}

Finalmente, el código para inicializar la animación se ve muy similar al ejemplo animate2. El método initState() crea un AnimationController y un Tween, y luego los vincula con animate(). La magia sucede en el método build(), que devuelve un objeto GrowTransition con un LogoWidget como child, y un objeto de animación para impulsar la transición. Estos son los tres elementos enumerados en las viñetas anteriores.

dart
void main() => runApp(const LogoApp());

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

  // Leave out the height and width so it fills the animating parent.
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: const FlutterLogo(),
    );
  }
}

class GrowTransition extends StatelessWidget {
  const GrowTransition({
    required this.child,
    required this.animation,
    super.key,
  });

  final Widget child;
  final Animation<double> animation;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: animation,
        builder: (context, child) {
          return SizedBox(
            height: animation.value,
            width: animation.value,
            child: child,
          );
        },
        child: child,
      ),
    );
  }
}

class LogoApp extends StatefulWidget {
  // ...

  @override
  Widget build(BuildContext context) => AnimatedLogo(animation: animation);
  Widget build(BuildContext context) {
    return GrowTransition(
      animation: animation,
      child: const LogoWidget(),
    );
  }

  // ...
}

App source: animate4

Animaciones simultáneas

#

En esta sección, te basarás en el ejemplo de monitorear el progreso de la animación (animate3), que usó AnimatedWidget para animarse hacia adentro y hacia afuera continuamente. Considera el caso en el que deseas animar hacia adentro y hacia afuera mientras la opacidad se anima de transparente a opaca.

Cada tween gestiona un aspecto de la animación. Por ejemplo:

dart
controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
);
sizeAnimation = Tween<double>(begin: 0, end: 300).animate(controller);
opacityAnimation = Tween<double>(begin: 0.1, end: 1).animate(controller);

Puedes obtener el tamaño con sizeAnimation.value y la opacidad con opacityAnimation.value, pero el constructor de AnimatedWidget solo toma un único objeto Animation. Para resolver este problema, el ejemplo crea sus propios objetos Tween y calcula explícitamente los valores.

Cambia AnimatedLogo para encapsular sus propios objetos Tween, y su método build() llama a Tween.evaluate() en el objeto de animación del padre para calcular los valores de tamaño y opacidad requeridos. El siguiente código muestra los cambios resaltados:

dart
class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({super.key, required Animation<double> animation})
    : super(listenable: animation);

  // Make the Tweens static because they don't change.
  static final _opacityTween = Tween<double>(begin: 0.1, end: 1);
  static final _sizeTween = Tween<double>(begin: 0, end: 300);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Opacity(
        opacity: _opacityTween.evaluate(animation),
        child: Container(
          margin: const EdgeInsets.symmetric(vertical: 10),
          height: _sizeTween.evaluate(animation),
          width: _sizeTween.evaluate(animation),
          child: const FlutterLogo(),
        ),
      ),
    );
  }
}

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

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    animation = CurvedAnimation(parent: controller, curve: Curves.easeIn)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });
    controller.forward();
  }

  @override
  Widget build(BuildContext context) => AnimatedLogo(animation: animation);

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

Código fuente de la app: El objeto animate5 conoce el estado actual de una animación (por ejemplo, si se ha iniciado, detenido, o si se mueve hacia adelante o en reversa), pero no sabe nada sobre lo que aparece en pantalla.

  • Un AnimationController gestiona la Animation.
  • Una CurvedAnimation define la progresión como una curva no lineal.
  • Un Tween interpola entre un valor inicial y final para una propiedad que se está animando.

Próximos pasos

#

Este tutorial te da una base para crear animaciones en Flutter usando Tweens, pero hay muchas otras clases para explorar. Podrías investigar las clases Tween especializadas, animaciones específicas para tu tipo de sistema de diseño, ReverseAnimation, transiciones de elementos compartidos (también conocidas como animaciones Hero), simulaciones de física y métodos fling().