Uma animação escalonada consiste em animações sequenciais ou sobrepostas. A animação pode ser puramente sequencial, com uma alteração ocorrendo após a próxima, ou pode se sobrepor parcial ou completamente. Quando a animação é sequencial, os elementos são animados sequencialmente com um pequeno atraso entre os horários de início de cada elemento. Isso cria um efeito cascata ou ondulante, onde a animação parece se mover pelos elementos em etapas, em vez de todos de uma vez.
Animações escalonadas são consideradas um tipo de microinteração porque aprimoram a experiência do usuário, fornecendo feedback sutil e interativo que orienta o usuário através de uma interface. No Flutter, você pode construir microinterações criando animações sutis usando animação implícita ou explícita.
Para contextualizar, as animações implícitas são projetadas para serem simples e fáceis de usar porque os detalhes da animação são abstraídos, enquanto as animações explícitas são mais adequadas para animações complexas porque oferecem controle completo do processo de animação. Isso é especialmente usado quando você precisa ter um controle mais refinado sobre a animação.
Neste artigo, iremos nos aprofundar no conceito de microinterações e, em seguida, para um caso de uso de microinterações, usaremos animação explícita para criar uma animação escalonada que anima os filhos contidos em um widget de coluna.
Ótimos produtos são produtos que oferecem bons recursos e detalhes. Os recursos levam as pessoas aos seus produtos, mas os detalhes as mantêm. Esses detalhes são coisas que fazem seu aplicativo se destacar dos demais. Com microinterações, você pode criar esses detalhes, fornecendo feedback agradável aos seus usuários.
O conceito de microinterações baseia-se na ideia de que pequenos detalhes podem ter um grande impacto na experiência geral do usuário. Microinterações podem ser usadas para cumprir funções essenciais, como comunicar feedback ou o resultado de uma ação. Exemplos de microinterações incluem:
Abaixo, podemos ver aplicações reais de microinterações, essas animações sutis são criadas em Flutter, para elevar a experiência do usuário. 👇🏽
As referências de design para esses aplicativos foram obtidas no Dribbble
Neste exemplo, criaremos uma animação escalonada que anima os filhos em um widget de coluna quando a página é deslizada. Isso será construído usando a abordagem de animação explícita porque, neste caso, precisaremos ter controle total de como queremos que a animação seja executada.
Veja como ficará a animação quando terminarmos 👇🏽
Para aproveitar ao máximo este tutorial, você deve ter o seguinte:
Depois de verificar todos os pré-requisitos do projeto, vamos nos aprofundar.
Primeiramente construiremos a tela principal que contém essas duas páginas. Essas duas páginas serão agrupadas em um widget de visualização de página que controla qual página será exibida ao deslizar. Além disso, na parte inferior da tela principal, temos um indicador que nos mostra a página em que estamos atualmente.
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, ), ), ) ); } }
O SmoothPageIndicator
usado no trecho de código acima pode ser encontrado em pub.dev . Aqui, o SmoothPageIndicator
é usado para mostrar a página atual em visualização. Você pode adicionar o pacote ao seu pubspec.yaml
assim 👇🏽
dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0
Nas duas páginas teremos um widget de coluna com vários widgets de cartão vazio. Esses widgets de cartão vazio serão usados para preencher a coluna para que possamos ver e apreciar totalmente a animação escalonada. Criaremos o widget de cartão vazio como um componente reutilizável para que não tenhamos que construí-lo do zero em todos os lugares em que o usarmos.
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), ) ] ), ); } }
Com todos esses códigos padronizados resolvidos, começaremos agora a construir a animação. Primeiro, criaremos um novo widget com estado que chamaremos de AnimateWidget
. Terá vários parâmetros para controlar como a animação será executada.
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(); }
Com os parâmetros do AnimateWidget
vistos no trecho acima, podemos controlar com sucesso:
pageController
, fazendo com que ele acione a animação quando a página é deslizada.
A seguir, no AnimateWiget
, definiremos o seguinte:
AnimationController
: Usado para controlar a sequência de animação.
Página atual: será usada para armazenar os dados da página atual.
Timer: Será utilizado para acionar a animação após algum atraso; é isso que provocará o efeito cascata da animação 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()); }
Analisando o trecho de código acima, você notará que:
AnimationController
para controlar a sequência de animação, por isso introduzimos o SingleTickerProviderStateMixin
.
init state
, inicializamos o AnimationController
e, em seguida, acionamos a animação usando animationController.forward
, na entrada da página e no deslizamento da página.
dispose
, limpamos os recursos.
AutomaticKeepAliveClientMixin
para preservar o estado do widget. Isso evita o descarte do AnimationController
, quando uma página é deslizada e não fica mais visível.
Na função getDelay
, calculamos o atraso antes da animação ser acionada para cada elemento. Para conseguir isso, dividimos a duração em milissegundos por 6 e aproximamos os resultados, depois multiplicamos pela posição. Esta é a posição (neste caso, o índice) do elemento.
No método Build, retornamos um AnimatedBuilder
. Neste construtor animado, retornaremos uma função de widget chamada _slideAnimation
que retorna um widget Transform.translate
. Na função _slideAnimation
, temos a função offsetAnimation
.
A função offsetAnimation
retorna a propriedade Animation que é usada no widget Transform.translate
. O widget Transform.translate
anima o widget filho, usando o valor da animação.
@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 é o código completo da classe 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 seguir, para usar esta classe AnimateWidget
em um widget de coluna, criaremos uma classe com um método estático chamado toStaggeredList
que retorna uma lista. Neste método, passamos todos os parâmetros necessários, incluindo uma lista children
. O parâmetro children
é onde passaríamos a lista de elementos que iremos animar.
A seguir, mapearemos os filhos, envolvendo cada filho com o 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(); }
No AnimateWidget
, passamos os parâmetros necessários para animar com sucesso cada filho da lista. Usando o método AnimateList.toStaggeredList
, agora podemos implementá-lo nas duas páginas nas quais estamos trabalhando.
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), ], ), ), ], ), ), ); } }
Nos filhos do widget da coluna, passaremos AnimateList.toStaggeredList
e passaremos os parâmetros necessários, incluindo os widgets que serão exibidos na coluna. Com isso, criamos com sucesso uma animação escalonada acionada ao deslizar. Você pode conferir o código completo aqui .
Este é o nosso resultado final:
Chegamos ao final deste tutorial. Neste ponto, cobrimos o conceito de microinterações e seu impacto na experiência do usuário. Também passamos pelo processo de construção de animação escalonada de itens em um widget de coluna acionado ao deslizar a página.
Existem muitos tipos de microinterações que você pode adicionar ao seu aplicativo para melhorar a experiência do usuário em seu projeto; você pode experimentar criando animações mais interativas no Flutter.
Se você achou este artigo útil, você pode apoiá-lo deixando um like ou comentário. Você também pode me seguir para mais artigos relacionados.