Saltar al contenido principal

Crear una animación de menú escalonada

Cómo implementar una animación de menú escalonado.

Una sola pantalla de aplicación puede contener múltiples animaciones. Reproducir todas las animaciones al mismo tiempo puede resultar abrumador. Reproducir las animaciones una tras otra puede llevar demasiado tiempo. Una mejor opción es escalonar las animaciones. Cada animación comienza en un momento diferente, pero las animaciones se superponen para crear una duración total más corta. En esta receta, construirás un menú lateral (drawer) con contenido animado que se escalona y tiene un botón que aparece en la parte inferior.

La siguiente animación muestra el comportamiento de la app:

Ejemplo de animación de menú escalonado

Crear el menú sin animaciones

#

El menú lateral (drawer) muestra una lista de títulos, seguida de un botón de Comenzar (Get started) en la parte inferior del menú.

Define un Widget Stateful llamado Menu que muestre la lista y el botón en ubicaciones estáticas.

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

  @override
  State<Menu> createState() => _MenuState();
}

class _MenuState extends State<Menu> {
  static const _menuTitles = [
    'Declarative Style',
    'Premade Widgets',
    'Stateful Hot Reload',
    'Native Performance',
    'Great Community',
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Stack(
        fit: StackFit.expand,
        children: [_buildFlutterLogo(), _buildContent()],
      ),
    );
  }

  Widget _buildFlutterLogo() {
    // TODO: We'll implement this later.
    return Container();
  }

  Widget _buildContent() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SizedBox(height: 16),
        ..._buildListItems(),
        const Spacer(),
        _buildGetStartedButton(),
      ],
    );
  }

  List<Widget> _buildListItems() {
    final listItems = <Widget>[];
    for (var i = 0; i < _menuTitles.length; ++i) {
      listItems.add(
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
          child: Text(
            _menuTitles[i],
            textAlign: TextAlign.left,
            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
          ),
        ),
      );
    }
    return listItems;
  }

  Widget _buildGetStartedButton() {
    return SizedBox(
      width: double.infinity,
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: ElevatedButton(
          style: ElevatedButton.styleFrom(
            shape: const StadiumBorder(),
            backgroundColor: Colors.blue,
            padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
          ),
          onPressed: () {},
          child: const Text(
            'Get Started',
            style: TextStyle(color: Colors.white, fontSize: 22),
          ),
        ),
      ),
    );
  }
}

Prepararse para las animaciones

#

El control del tiempo de la animación requiere un AnimationController.

Añade el SingleTickerProviderStateMixin a la clase MenuState. Luego, declara e instancia un AnimationController.

dart
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  late AnimationController _staggeredController;

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

    _staggeredController = AnimationController(vsync: this);
  }

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

La duración del retraso antes de cada animación depende de ti. Define los retrasos de las animaciones, las duraciones de las animaciones individuales y la duración total de la animación.

dart
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  static const _initialDelayTime = Duration(milliseconds: 50);
  static const _itemSlideTime = Duration(milliseconds: 250);
  static const _staggerTime = Duration(milliseconds: 50);
  static const _buttonDelayTime = Duration(milliseconds: 150);
  static const _buttonTime = Duration(milliseconds: 500);
  final Duration _animationDuration =
      _initialDelayTime +
      (_staggerTime * _menuTitles.length) +
      _buttonDelayTime +
      _buttonTime;
}

En este caso, todas las animaciones se retrasan 50 ms. Después de eso, los elementos de la lista comienzan a aparecer. La aparición de cada elemento de la lista se retrasa 50 ms después de que el elemento de lista anterior comience a deslizarse. Cada elemento de la lista tarda 250 ms en deslizarse de derecha a izquierda. Después de que el último elemento de la lista comience a deslizarse, el botón en la parte inferior espera otros 150 ms para aparecer. La animación del botón tarda 500 ms.

Con cada retraso y duración de animación definidos, se calcula la duración total para que pueda usarse para calcular los tiempos de animación individuales.

Los tiempos de animación deseados se muestran en el siguiente diagrama:

Diagrama de tiempos de animación

Para animar un valor durante una subsección de una animación más grande, Flutter proporciona la clase Interval. Un Interval toma un porcentaje de tiempo de inicio y un porcentaje de tiempo de finalización. Ese Interval se puede usar luego para animar un valor entre esos tiempos de inicio y finalización, en lugar de usar los tiempos de inicio y finalización de toda la animación. Por ejemplo, dada una animación que tarda 1 segundo, un intervalo de 0.2 a 0.5 comenzaría a los 200 ms (20%) y terminaría a los 500 ms (50%).

Declara y calcula el Interval de cada elemento de la lista y el Interval del botón inferior.

dart
class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  final List<Interval> _itemSlideIntervals = [];
  late Interval _buttonInterval;

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

    _createAnimationIntervals();

    _staggeredController = AnimationController(
      vsync: this,
      duration: _animationDuration,
    );
  }

  void _createAnimationIntervals() {
    for (var i = 0; i < _menuTitles.length; ++i) {
      final startTime = _initialDelayTime + (_staggerTime * i);
      final endTime = startTime + _itemSlideTime;
      _itemSlideIntervals.add(
        Interval(
          startTime.inMilliseconds / _animationDuration.inMilliseconds,
          endTime.inMilliseconds / _animationDuration.inMilliseconds,
        ),
      );
    }

    final buttonStartTime =
        Duration(milliseconds: _menuTitles.length * 50) + _buttonDelayTime;
    final buttonEndTime = buttonStartTime + _buttonTime;
    _buttonInterval = Interval(
      buttonStartTime.inMilliseconds / _animationDuration.inMilliseconds,
      buttonEndTime.inMilliseconds / _animationDuration.inMilliseconds,
    );
  }
}

Animar los elementos de la lista y el botón

#

La animación escalonada se reproduce tan pronto como el menú se vuelve visible.

Inicia la animación en initState().

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

  _createAnimationIntervals();

  _staggeredController = AnimationController(
    vsync: this,
    duration: _animationDuration,
  )..forward();
}

Cada elemento de la lista se desliza de derecha a izquierda y se desvanece al mismo tiempo.

Utiliza el Interval del elemento de la lista y una curva easeOut para animar los valores de opacidad y traslación de cada elemento de la lista.

dart
List<Widget> _buildListItems() {
  final listItems = <Widget>[];
  for (var i = 0; i < _menuTitles.length; ++i) {
    listItems.add(
      AnimatedBuilder(
        animation: _staggeredController,
        builder: (context, child) {
          final animationPercent = Curves.easeOut.transform(
            _itemSlideIntervals[i].transform(_staggeredController.value),
          );
          final opacity = animationPercent;
          final slideDistance = (1.0 - animationPercent) * 150;

          return Opacity(
            opacity: opacity,
            child: Transform.translate(
              offset: Offset(slideDistance, 0),
              child: child,
            ),
          );
        },
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
          child: Text(
            _menuTitles[i],
            textAlign: TextAlign.left,
            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
          ),
        ),
      ),
    );
  }
  return listItems;
}

Utiliza el mismo enfoque para animar la opacidad y la escala del botón inferior. Esta vez, utiliza una curva elasticOut para darle al botón un efecto elástico.

dart
Widget _buildGetStartedButton() {
  return SizedBox(
    width: double.infinity,
    child: Padding(
      padding: const EdgeInsets.all(24),
      child: AnimatedBuilder(
        animation: _staggeredController,
        builder: (context, child) {
          final animationPercent = Curves.elasticOut.transform(
            _buttonInterval.transform(_staggeredController.value),
          );
          final opacity = animationPercent.clamp(0.0, 1.0);
          final scale = (animationPercent * 0.5) + 0.5;

          return Opacity(
            opacity: opacity,
            child: Transform.scale(scale: scale, child: child),
          );
        },
        child: ElevatedButton(
          style: ElevatedButton.styleFrom(
            shape: const StadiumBorder(),
            backgroundColor: Colors.blue,
            padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 14),
          ),
          onPressed: () {},
          child: const Text(
            'Get Started',
            style: TextStyle(color: Colors.white, fontSize: 22),
          ),
        ),
      ),
    ),
  );
}

¡Felicitaciones! Tienes un menú animado donde la aparición de cada elemento de la lista está escalonada, seguido de un botón inferior que aparece en su lugar.

Ejemplo interactivo

#