Animated Icons: The Missing Piece to Your Bottom Nav in Flutter & Rive
Welcome to episode 3 of Rive and Flutter: A Match Made in Animation Heaven, where we will explore the process of creating a custom bottom navigation bar, adding animated icons and an animated indicator for the selected tab, which can greatly improve the overall design and functionality of the app. So, if you want to take your mobile app design to the next level, read on to learn how to create a visually appealing and functional bottom navigation bar using Flutter and Rive Animation.
Here is the link to Episode 2 in case you missed it
Bottom Navigation Bar
Begin by creating a new file named entry_point.dart
within the lib
directory. Then, create a stateful widget, named 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
],
),
),
),
);
}
}
In this case, the Scaffold
body is an empty container, while the bottomNavigationBar
is a container with added margin and padding. Additionally, a background color and border radius have been applied to the BoxDecoration. The child
of the container is a Row
, but it currently has no children. We will add the bottom navigation items to this area later.
Animated Icons
Before incorporating any animated icons into the bottom navigation bar, let’s first create a model for those icons, which we will name 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;
}
}
In the src
field, specify the asset name or URL. In this example, we are using animated icons created by the Rive community, Animated Icon Set — 1.
If you open it on Rive you will notice, each animation has its own artboard and state machine name. Additionally, each icon has an input that is used to identify whether the animation is currently playing or not.
Let’s create a variable called bottomNavs
which is a List of RiveAsset. This list will contain all the icons that we will use in our bottom navigation bar.
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"),
];
The Animated Icon Set — 1 has already been added to the assets directory as a icons.riv
file, and here we will utilize it. This one file contains all the animated icons. To specify which icon we want to use, we can define the artboard
and stateMachineName
.
Create a new variable called selectedBottomNav
and set the first bottom navigation item as the default selection.
RiveAsset selectedBottomNav = bottomNavs.first;
Replace TODO: Bottom nav items
with following code
...List.generate(
bottomNavs.length,
(index) => GestureDetector(
onTap: () {
// TODO: Play animation on tap
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// TODO: Animated Bar
SizedBox(
height: 36,
width: 36,
child: Opacity(
opacity: 1, // TODO: Chnage Opacity if not selected
child: RiveAnimation.asset(
bottomNavs[index].src,
artboard: bottomNavs[index].artboard,
onInit: (artboard) {
// TODO: Set the input value
},
),
),
),
],
),
),
)
Adding a Rive asset to our app is similar to adding an image, where we define the source. We will wrap the RiveAnimation
widget with the Opacity
widget to reduce the icon’s opacity when it is not selected. The SizedBox
widget will help us to define the height and width, ensuring that each icon takes up the same space. Finally, we will wrap everything in a GestureDetector
to detect when the user taps on the icon.
To ensure proper spacing between the icons, we will replace the TODO: Set mainAxisAlignment
with the following code:
mainAxisAlignment: MainAxisAlignment.spaceAround
Let’s animate our icons when they are tapped. To do this, we need to obtain the input that controls whether the animation should play or stop. In the lib
directory, create a new directory called utils
and within it, create a file called rive_utils.dart
. Create a method called getRiveController
that returns the 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;
}
}
It’s time to call the helper method, insert the following code in place of TODO: Set the input value
StateMachineController controller =
RiveUtils.getRiveController(artboard,
stateMachineName:
bottomNavs[index].stateMachineName);
bottomNavs[index].input =
controller.findSMI("active") as SMIBool;
Replace the TODO: Play animation on tap
with the following code.
bottomNavs[index].input!.change(true);
if (bottomNavs[index] != selectedBottomNav) {
setState(() {
selectedBottomNav = bottomNavs[index];
});
}
Future.delayed(const Duration(seconds: 1), () {
bottomNavs[index].input!.change(false);
});
To animate the icons when tapped, we first change the icon’s input value to true
, which triggers the animation. Then, we check if the selected bottom tab is the same as the current one. If not, we update the selectedBottomNav
to the current tab. The input being set to true will continuously play the icon animation, but we want it to play only once. Since each animation takes 1 second to complete, after 1 second we reset the input value to false
.
To enhance the user experience, set the opacity of the unsettled tabs to 0.5. Locate the line TODO: Change Opacity if not selected
, then alter the opacity to 1
with the following code.
bottomNavs[index] == selectedBottomNav ? 1 : 0.5
Animated Bar
We’re nearly finished, just need to add the animated indicator on the selected tab. In the lib
directory, create a components
directory and inside it, create a file named 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)),
),
);
}
}
Name it AnimatedBar
with a boolean property isActive
to identify its active state. The AnimatedBar
is an AnimatedContainer
with a height of 4, an animation duration of 20 milliseconds, and a 2-pixel bottom margin. If it’s active, its width is 20, otherwise it’s 0. Also, add a color and a border radius.
Return to the entry_point.dart
and replace the TODO: Animated Bar
with the following code.
AnimatedBar(isActive: bottomNavs[index] == selectedBottomNav),
Home Page
We’ve completed the bottom animation, but the screen is blank. Let’s now create the core of any app, the HomePage.
In the HomePage, there’s no complex UI, so I won’t go into detail. First, go to the screen
directory and create a new directory called home
, then inside it, create a file named 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
],
),
),
),
);
}
}
Let’s create a Course
model with parameters for title, description, icon, and 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",
),
];
In the home
directory, create a new directory called components
and inside it, create a file named 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)
],
),
);
}
}
Replace the 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(),
],
),
),
To add the recent courses, replace the 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),
),
),
That’s it, we’ve finished our HomePage.👏
Working on the Episode 4…
🛍️ Checkout FlutterShop — premium Flutter UI kit with 100+ screens. [Live preview]
I would love to hear your thoughts on this tutorial and how you found it helpful. If you enjoyed it and found it useful, please don’t forget to give it a clap 👏. Your feedback is important to me and helps me to create content that is valuable and useful to you. Thank you for taking the time to read this tutorial, and I hope that it has been helpful in your animation journey with Rive and Flutter.