Ícones animados: Navegação inferior em Flutter e Rive
Rive e Flutter: A Match Made in Animation Heaven, onde exploraremos o processo de criação de uma barra de navegação inferior personalizada, adicionando ícones animados e um indicador animado para a guia selecionada, o que pode melhorar muito o design geral e a funcionalidade do aplicativo. Portanto, se você quiser levar o design do seu aplicativo móvel para o próximo nível, continue lendo para saber como criar uma barra de navegação inferior visualmente atraente e funcional usando o Flutter e o Rive Animation.
Conteudo
Bottom Navigation Bar
Comece criando um novo arquivo chamado entry_point.dart
no diretório lib. Em seguida, crie um widget com estado, chamado EntryPoint
.
class EntryPoint extends StatefulWidget { const EntryPoint({super.key}); @override State<EntryPoint> createState() => _EntryPointState(); } class _EntryPointState extends State<EntryPoint> { @override Widget build(BuildContext context) { return Scaffold( body: Container(), bottomNavigationBar: SafeArea( child: Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.symmetric(horizontal: 24), decoration: BoxDecoration( color: backgroundColor2.withOpacity(0.8), borderRadius: const BorderRadius.all(Radius.circular(24)), ), child: Row( // TODO: Set mainAxisAlignment children: [ // TODO: Bottom nav items ], ), ), ), ); } }
Nesse caso, o corpo do Scaffold
é um contêiner vazio, enquanto a bottomNavigationBar
é um contêiner com margem e preenchimento adicionais. Além disso, uma cor de fundo e um raio de borda foram aplicados à BoxDecoration. O child
do contêiner é um Row
, mas atualmente não tem filhos. Adicionaremos os itens de navegação inferior a essa área posteriormente.
Ícones animados
Antes de incorporar qualquer ícone animado na barra de navegação inferior, vamos primeiro criar um modelo para esses ícones, que chamaremos de RiveAsset
.
import 'package:rive/rive.dart'; class RiveAsset { final String artboard, stateMachineName, title, src; late SMIBool? input; RiveAsset(this.src, {required this.artboard, required this.stateMachineName, required this.title, this.input}); set setInput(SMIBool status) { input = status; } }
No campo src
, especifique o nome ou URL do ativo. Neste exemplo, estamos usando ícones animados criados pela comunidade Rive, Animated Icon Set — 1.
Animated Icon Set 1 – Visualização no Rive
Ao abri-lo no Rive, você perceberá que cada animação tem sua própria prancheta e nome de máquina de estado. Além disso, cada ícone tem uma entrada que é usada para identificar se a animação está sendo reproduzida no momento ou não.
Vamos criar uma variável chamada bottomNavs
, que é uma lista de RiveAsset. Essa lista conterá todos os ícones que usaremos em nossa barra de navegação inferior.
List<RiveAsset> bottomNavs = [ RiveAsset("assets/RiveAssets/icons.riv", artboard: "CHAT", stateMachineName: "CHAT_Interactivity", title: "Chat"), RiveAsset("assets/RiveAssets/icons.riv", artboard: "SEARCH", stateMachineName: "SEARCH_Interactivity", title: "Search"), RiveAsset("assets/RiveAssets/icons.riv", artboard: "TIMER", stateMachineName: "TIMER_Interactivity", title: "Chat"), RiveAsset("assets/RiveAssets/icons.riv", artboard: "BELL", stateMachineName: "BELL_Interactivity", title: "Notifications"), RiveAsset("assets/RiveAssets/icons.riv", artboard: "USER", stateMachineName: "USER_Interactivity", title: "Profile"), ];
O Animated Icon Set – 1 já foi adicionado ao diretório de ativos como um arquivo icons.riv
, e aqui nós o utilizaremos. Esse único arquivo contém todos os ícones animados. Para especificar qual ícone queremos usar, podemos definir a artboard
e o stateMachineName
.
Crie uma nova variável chamada selectedBottomNav
e defina o primeiro item de navegação inferior como a seleção padrão.
RiveAsset selectedBottomNav = bottomNavs.first;
Substitua TODO: itens de navegação inferiores pelo código a seguir
...List.generate( bottomNavs.length, (index) => GestureDetector( onTap: () { // TODO: Reproduzir animação ao tocar }, child: Column( mainAxisSize: MainAxisSize.min, children: [ // TODO: Barra Animada SizedBox( height: 36, width: 36, child: Opacity( opacity: 1, // TODO: Alterar a opacidade se não estiver selecionado child: RiveAnimation.asset( bottomNavs[index].src, artboard: bottomNavs[index].artboard, onInit: (artboard) { // TODO:Defina o valor de entrada }, ), ), ), ], ), ), )
A adição de um ativo Rive ao nosso aplicativo é semelhante à adição de uma imagem, em que definimos a fonte. Envolveremos o widget RiveAnimation
com o widget Opacity para reduzir a opacidade do ícone quando ele não estiver selecionado. O widget SizedBox
nos ajudará a definir a altura e a largura, garantindo que cada ícone ocupe o mesmo espaço. Por fim, envolveremos tudo em um GestureDetector
para detectar quando o usuário tocar no ícone.
Para garantir o espaçamento adequado entre os ícones, substituiremos o TODO: Set mainAxisAlignment
pelo seguinte código:
mainAxisAlignment: MainAxisAlignment.spaceAround
Vamos animar nossos ícones quando eles forem tocados. Para fazer isso, precisamos obter a entrada que controla se a animação deve ser reproduzida ou interrompida. No diretório lib, crie um novo diretório chamado utils
e, dentro dele, crie um arquivo chamado rive_utils.dart
. Crie um método chamado getRiveController
que retorne o StateMachineController.
import 'package:rive/rive.dart'; class RiveUtils { static StateMachineController getRiveController(Artboard artboard, {stateMachineName = "State Machine 1"}) { StateMachineController? controller = StateMachineController.fromArtboard(artboard, stateMachineName); artboard.addController(controller!); return controller; } }
É hora de chamar o método auxiliar, insira o seguinte código no lugar de TODO: Set the input value
StateMachineController controller = RiveUtils.getRiveController(artboard, stateMachineName: bottomNavs[index].stateMachineName); bottomNavs[index].input = controller.findSMI("active") as SMIBool;
Substitua a animação TODO: Play on tap pelo código a seguir
.
bottomNavs[index].input!.change(true); if (bottomNavs[index] != selectedBottomNav) { setState(() { selectedBottomNav = bottomNavs[index]; }); } Future.delayed(const Duration(seconds: 1), () { bottomNavs[index].input!.change(false); });
Para animar os ícones quando tocados, primeiro alteramos o valor de entrada do ícone para true
, o que aciona a animação. Em seguida, verificamos se a guia inferior selecionada é a mesma que a atual. Caso contrário, atualizamos o selectedBottomNav
para a guia atual. A entrada definida como true reproduzirá continuamente a animação do ícone, mas queremos que ela seja reproduzida apenas uma vez. Como cada animação leva 1 segundo para ser concluída, após 1 segundo, redefinimos o valor do input para false
.
Para aprimorar a experiência do usuário, defina a opacidade das guias não definidas como 0,5. Localize a linha TODO: Change Opacity if not selected
e, em seguida, altere a opacidade para 1
com o seguinte código.
bottomNavs[index] == selectedBottomNav ? 1 : 0.5
Animated Bar
Estamos quase terminando, só precisamos adicionar o indicador animado na guia selecionada. No diretório lib
, crie um diretório de componentes
e, dentro dele, crie um arquivo chamado animated_bar.dart
class AnimatedBar extends StatelessWidget { const AnimatedBar({ Key? key, required this.isActive, }) : super(key: key); final bool isActive; @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 200), margin: const EdgeInsets.only(bottom: 2), height: 4, width: isActive ? 20 : 0, decoration: const BoxDecoration( color: Color(0xFF81B4FF), borderRadius: BorderRadius.all(Radius.circular(12)), ), ); } }
Nomeie-a AnimatedBar
com uma propriedade booleana isActive
para identificar seu estado ativo. A AnimatedBar
é um AnimatedContainer
com uma altura de 4, uma duração de animação de 20 milissegundos e uma margem inferior de 2 pixels. Se estiver ativa, sua largura será 20, caso contrário, será 0. Além disso, adicione uma cor e um raio de borda.
Retorne ao entry_point.dart
e substitua o TODO: Animated Bar
pelo código a seguir.
AnimatedBar(isActive: bottomNavs[index] == selectedBottomNav),
Home Page
Concluímos a animação inferior, mas a tela está em branco. Vamos agora criar o núcleo de qualquer aplicativo, a HomePage.
Visualização completa da HomePage
Na HomePage, não há uma interface de usuário complexa, portanto, não entrarei em detalhes. Primeiro, vá até o diretório screen
e crie um novo diretório chamado home
e, dentro dele, crie um arquivo chamado home_screen.dart
class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( bottom: false, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 40), Padding( padding: const EdgeInsets.all(20), child: Text( "Courses", style: Theme.of(context).textTheme.headlineMedium!.copyWith( color: Colors.black, fontWeight: FontWeight.w600), ), ), // TODO: Courses with the horizontal scroll // TODO: Recent courses ], ), ), ), ); } }
Vamos criar um model Course
com parâmetros para title, description, icon e background color.
import 'package:flutter/material.dart'; class Course { final String title, description, iconSrc; final Color bgColor; Course({ required this.title, this.description = "Build and animate an iOS app from scratch", this.iconSrc = "assets/icons/ios.svg", this.bgColor = const Color(0xFF7553F6), }); } // demo courses list List<Course> courses = [ Course(title: "Animations in SwiftUI"), Course( title: "Animations in Flutter", iconSrc: "assets/icons/code.svg", bgColor: const Color(0xFF80A4FF), ), ]; // demo recent courses List<Course> recentCourses = [ Course(title: "State Machine"), Course( title: "Animated Menu", bgColor: const Color(0xFF9CC5FF), iconSrc: "assets/icons/code.svg", ), Course(title: "Flutter with Rive"), Course( title: "Animated Menu", bgColor: const Color(0xFF9CC5FF), iconSrc: "assets/icons/code.svg", ), ];
No diretório home
, crie um novo diretório chamado components
e dentro dele, crie um arquivo chamado course_card.dart
.
import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../../models/course.dart'; class CourseCard extends StatelessWidget { const CourseCard({ Key? key, required this.course, }) : super(key: key); final Course course; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), height: 280, width: 260, decoration: BoxDecoration( color: course.bgColor, borderRadius: const BorderRadius.all(Radius.circular(20)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( course.title, style: Theme.of(context).textTheme.titleLarge!.copyWith( color: Colors.white, fontWeight: FontWeight.w600), ), Padding( padding: const EdgeInsets.only(top: 12, bottom: 8), child: Text( course.description, style: const TextStyle(color: Colors.white70), ), ), const Text( "61 SECTIONS - 11 HOURS", style: TextStyle(color: Colors.white54), ), const Spacer(), Row( children: List.generate( 3, (index) => Transform.translate( offset: Offset((-10 * index).toDouble(), 0), child: CircleAvatar( radius: 20, backgroundImage: AssetImage( "assets/avaters/Avatar ${index + 1}.jpg"), ), ), ), ) ], ), ), SvgPicture.asset(course.iconSrc) ], ), ); } }
Substitua os cursos TODO: Courses with the horizontal scroll with the following code.
SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ ...courses .map((course) => Padding( padding: const EdgeInsets.only(left: 20), child: CourseCard(course: course), )) .toList(), ], ), ),
Para adicionar os cursos recentes, substitua o TODO: Recent courses with the following code.
Padding( padding: const EdgeInsets.all(20), child: Text( "Recent", style: Theme.of(context) .textTheme .headlineSmall! .copyWith(fontWeight: FontWeight.w600), ), ), ...recentCourses.map( (course) => Padding( padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), child: SecondaryCourseCard(course: course), ), ),