Ícones animados: Navegação inferior em Flutter e Rive

Tempo de leitura: 7 minutes

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.

 

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),
  ),
),