Una animación escalonada consta de animaciones secuenciales o superpuestas. La animación puede ser puramente secuencial, con un cambio sucediendo después del siguiente, o puede superponerse total o parcialmente. Cuando la animación es secuencial, los elementos se animan secuencialmente con un ligero retraso entre las horas de inicio de cada elemento. Esto crea un efecto en cascada o dominó, donde la animación parece moverse a través de los elementos en etapas en lugar de hacerlo todos a la vez.
Las animaciones escalonadas se consideran un tipo de microinteracción porque mejoran la experiencia del usuario al proporcionar retroalimentación sutil e interactiva que guía al usuario a través de una interfaz. En Flutter, puedes crear microinteracciones creando animaciones sutiles utilizando la animación implícita o explícita.
Para el contexto, las animaciones implícitas están diseñadas para ser simples y fáciles de usar porque los detalles de la animación se abstraen, mientras que las animaciones explícitas son más adecuadas para animaciones complejas porque ofrecen un control completo del proceso de animación. Esto se usa especialmente cuando necesita tener un control más detallado sobre la animación.
En este artículo, profundizaremos en el concepto de microinteracciones y luego, para un caso de uso de microinteracciones, usaremos animación explícita para crear una animación escalonada que anime a los elementos secundarios contenidos en un widget de columna.
Los grandes productos son productos que ofrecen buenas características y detalles. Las funciones atraen a las personas a sus productos, pero los detalles las mantienen. Estos detalles son cosas que hacen que su aplicación se destaque de las demás. Con las microinteracciones, puede crear estos detalles brindando comentarios interesantes a sus usuarios.
El concepto de microinteracciones se basa en la idea de que los pequeños detalles pueden tener un gran impacto en la experiencia general del usuario. Las microinteracciones se pueden utilizar para cumplir funciones esenciales como comunicar comentarios o el resultado de una acción. Ejemplos de microinteracciones incluyen:
A continuación, podemos ver aplicaciones de microinteracciones en la vida real; estas animaciones sutiles se crean en Flutter para mejorar la experiencia del usuario. 👇🏽
Las referencias de diseño para estas aplicaciones se obtuvieron de Dribbble.
En este ejemplo, crearemos una animación escalonada que animará a los niños en un widget de columna cuando se pase esa página. Esto se creará utilizando el enfoque de animación explícita porque, en este caso, necesitaremos tener control total de cómo queremos que se ejecute la animación.
Así es como se verá la animación cuando hayamos terminado 👇🏽
Para aprovechar al máximo este tutorial, debe tener lo siguiente:
Una vez que haya comprobado todos los requisitos previos del proyecto, profundicemos.
Primero, construiremos la pantalla principal que contiene estas dos páginas. Estas dos páginas se incluirán en un widget de vista de página que controla qué página se muestra al deslizar el dedo. Además, en la parte inferior de la pantalla principal tenemos un indicador que nos muestra la página en la que nos encontramos actualmente.
class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } }
El SmoothPageIndicator
utilizado en el fragmento de código anterior se puede encontrar en pub.dev . Aquí, SmoothPageIndicator
se utiliza para mostrar la página actual a la vista. Puedes agregar el paquete a tu pubspec.yaml
así 👇🏽
dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0
En las dos páginas tendremos un widget de columna con varios widgets de tarjetas vacías. Estos widgets de tarjetas vacías se utilizarán para completar la columna para que podamos ver y apreciar completamente la animación escalonada. Crearemos el widget de tarjeta vacía como un componente reutilizable para no tener que crearlo desde cero en cada lugar donde lo usemos.
class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } }
Una vez eliminados todos estos códigos repetitivos, comenzaremos a crear la animación. Primero, crearemos un nuevo widget con estado al que llamaremos AnimateWidget
. Tendrá varios parámetros para controlar cómo se ejecutará la animación.
class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); }
Con los parámetros de AnimateWidget
como se ve en el fragmento anterior, podemos controlar con éxito:
pageController
, lo que hace que active la animación cuando se pasa la página.
A continuación, en AnimateWiget
, definiremos lo siguiente:
AnimationController
: se utiliza para controlar la secuencia de animación.
Página actual: se utilizará para contener los datos de la página actual.
Temporizador: se utilizará para activar la animación después de algún retraso; esto es lo que provocará el efecto de cascada de animación escalonada.
class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); }
Al desglosar el fragmento de código anterior, notarás que:
AnimationController
para controlar la secuencia de animación, por eso introdujimos SingleTickerProviderStateMixin
.
init state
, inicializamos AnimationController
y luego activamos la animación usando animationController.forward
, al ingresar a la página y al deslizar la página.
dispose
, limpiamos los recursos.
AutomaticKeepAliveClientMixin
para preservar el estado del widget. Esto es para evitar la eliminación de AnimationController
cuando se pasa una página y ya no es visible.
En la función getDelay
, calculamos el retraso antes de que se activara la animación para cada elemento. Para lograr esto, dividimos la duración en milisegundos por 6 y aproximamos los resultados, luego los multiplicamos por la posición. Esta es la posición (en este caso, el índice) del elemento.
En el método Build, devolvemos un AnimatedBuilder
. En este generador animado, devolveremos una función de widget llamada _slideAnimation
que devuelve un widget Transform.translate
. En la función _slideAnimation
, tenemos la función offsetAnimation
.
La función offsetAnimation
devuelve la propiedad Animation que se utiliza en el widget Transform.translate
. El widget Transform.translate
anima el widget secundario utilizando el valor de la animación.
@override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); }
Este es el código completo de la clase AnimateWidget
👇🏽
class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } }
A continuación, para usar esta clase AnimateWidget
en un widget de columna, crearemos una clase con un método estático llamado toStaggeredList
que devuelve una lista. En este método, pasamos todos los parámetros necesarios, incluida una lista children
. El parámetro children
es donde pasaríamos la lista de elementos que animaremos.
A continuación, asignaremos niños, envolviendo a cada niño con AnimateWidget
.
class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); }
En AnimateWidget
, pasamos los parámetros necesarios para animar correctamente a cada niño de la lista. Usando el método AnimateList.toStaggeredList
, ahora podemos implementarlo en las dos páginas en las que estamos trabajando.
class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } }
En los elementos secundarios del widget de columna, pasaremos AnimateList.toStaggeredList
y pasaremos los parámetros necesarios, incluidos los widgets que se mostrarán en la columna. Con esto, hemos creado con éxito una animación escalonada que se activa al deslizar el dedo. Puedes consultar el código completo aquí .
Este es nuestro resultado final:
Hemos llegado al final de este tutorial. En este punto, cubrimos el concepto de microinteracciones y su impacto en la experiencia del usuario. También pasamos por el proceso de creación de animaciones escalonadas de elementos en un widget de columna que se activa al deslizar la página.
Hay tantos tipos de microinteracciones que puedes agregar a tu aplicación para mejorar la experiencia del usuario de tu proyecto; Puedes experimentar creando animaciones más interactivas en Flutter.
Si este artículo te resultó útil, puedes apoyarlo dejando un me gusta o un comentario. También puedes seguirme para más artículos relacionados.