Flutter Animations: Getting Started
A tutorial on how to build a simple, yet functional animation in Flutter
I've always found it fascinating how ridiculously complex Flutter tutorials make animating a bouncing ball... or making some other useless for a real app animation. I'll try to make this article different. We will build a sliding tab indicator, like the one you can find in a TabBar
widget. The animation will only require one widget and one controller. Without further ado, let's get started!
You can check exactly what we are going to do in the TLDR;
Needle Indicator in the Haystack
First, let's create a widget of the indicator itself. We'll just have a slightly rounded, fixed width and height rectangle, like that:
class Indicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 30,
height: 3,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.vertical(top: Radius.circular(5))
),
);
}
}
Our indicator is supposed to slide under the icons equally distributed around the card. The indicator should slide on top of the card like it would in the case of Tabs. This is a case for the Stack
widget. We'll also need a button for triggering the animation, for now, it will not do anything. Let's build our "canvas" as a widget, allowing us to pass our Indicator and Triggering action as a child
and onButtonPressed
parameters:
class ShowcaseStackCard extends StatelessWidget {
final Widget child;
final Function()? onButtonPressed;
const ShowcaseStackCard({
super.key,
this.onButtonPressed,
required this.child,
});
@override
Widget build(BuildContext context) {
return Card(
child: SizedBox(
height: 80,
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: onButtonPressed,
child: Text('Slide Under Random Icon')
),
Row(
children: [
Expanded(child: Icon(Icons.square)),
Expanded(child: Icon(Icons.circle)),
Expanded(child: Icon(Icons.star)),
],
)
],
),
child
],
),
),
);
}
}
Let's see what we'll have now if we just pass a fresh Indicator
as a parameter:
ShowcaseStackCard(
child: Indicator(),
);
Placing the Indicator
As you may see, the indicator is now in the top left corner of the Stack
. Of course, that's not what we want. Although the Positioned
widget might sound like an obvious choice for positioning the widget inside a Stack
, Positioned
doesn't provide us a way to know the parent Stack
size, which is essential for proper placement. Instead, we'll use a combination of LayoutBuilder
for accessing parent size and Padding
for placement, relative to the top left corner. Let's wrap this in a widget:
class RelativelyPositioned extends StatelessWidget {
final Widget child;
final Offset Function(Size size) positionCalculator;
const RelativelyPositioned({
super.key,
required this.child,
required this.positionCalculator,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constrained) {
var position = positionCalculator(constrained.biggest);
return Padding(
padding: EdgeInsets.only(
top: position.dy,
left: position.dx
),
child: child,
);
}
);
}
}
Calculating top offset is trivial, it's just size.height - 3
. For the left offset let's calculate how much offset we'll need to do to end up under an icon at a particular index. Let's imagine our real estate hosting two occupants: spaces and indicators (places for indicators). Below each icon, we'll have: Left Spacing -> Indicator Spacing -> Right Spacing
. With that in mind here are the resulting calculations:
double calculateLeftOffset(int targetIndex, double totalWidth) {
var totalElementsCount = 3;
var indicatorWidth = 30;
var spaceWidth = (totalWidth - indicatorWidth * totalElementsCount) / (totalElementsCount * 2);
var usedBySpaces = (1 + (targetIndex * 2)) * spaceWidth;
var usedByIndicators = targetIndex * indicatorWidth;
return usedBySpaces + usedByIndicators;
}
Using the newly created widgets and function we'll be able to position our indicator under the rightmost icon (index = 2).
class TabsIndicatorCaseV2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ShowcaseStackCard(
child: RelativelyPositioned(
positionCalculator: (size) => Offset(
calculateLeftOffset(2, size.width),
size.height - 3
),
child: Indicator()
),
);
}
}
Indicator on It's Way
Now let's get back to the animation. When an animation is triggered our indicator should slowly move from the offset of the index from which the animation is starting towards the offset of the index where the indicator must end up. So to calculate indicator offset at any given time we will need to know:
sourceIndex
to calculate starting offsettargetIndex
to calculate target offset.progress
of the animation. With 0 meaning indicator is under the source, 1 meaning indicator is under the target, and 0.5 meaning indicator is half way through.
Here's what the resulting calculation will look like:
double calculateMovingLeftOffset({
required int sourceIndex,
required int targetIndex,
required double progress,
required double totalWidth
}) {
var targetPosition = calculateLeftOffset(targetIndex, totalWidth);
var sourcePosition = calculateLeftOffset(sourceIndex, totalWidth);
var distance = (targetPosition - sourcePosition).abs();
var passedDistance = distance * progress;
return targetPosition > sourcePosition
? sourcePosition + passedDistance
: sourcePosition - passedDistance;
}
With the calculation parameters in mind, we can figure out what our button should do. Besides knowing the current
selected index, we should also track the previous
selected index to correctly animate the widget. The flutter-way to organize the tracking will be to create a controller, implementing ValueListenable
. Here's how it will look in our case:
class IndexSelection {
final int current;
final int? previous;
IndexSelection(this.current, this.previous);
}
class IndexSelectionController extends ChangeNotifier implements ValueListenable<IndexSelection> {
IndexSelection _value = IndexSelection(0, null);
@override
IndexSelection get value => _value;
void select(int tabIndex) {
_value = IndexSelection(tabIndex, _value.current);
notifyListeners();
}
void switchRandom(int total) {
var random = Random();
var target = random.nextInt(total);
while (target == _value.current) {
target = random.nextInt(total);
}
select(target);
}
}
To combine this all in a functional widget we'll listen to our IndexSelectionController
utilizing ValueListenableBuilder
. Since we have not yet really implemented an animation we will hard code progress
to be 1
, meaning that the indicator will immediately end up in the target location. This is what we'll get:
class TabsIndicatorCaseV3 extends StatelessWidget {
final IndexSelectionController indexController = IndexSelectionController();
@override
Widget build(BuildContext context) {
return ShowcaseStackCard(
child: ValueListenableBuilder(
valueListenable: indexController,
builder: (context, v, child) {
return RelativelyPositioned(
positionCalculator: (size) => Offset(
calculateMovingLeftOffset(
sourceIndex: v.previous ?? 0,
targetIndex: v.current,
progress: 1,
totalWidth: size.width),
size.height - 3
),
child: Indicator());
}
),
onButtonPressed: () => indexController.switchRandom(3)
);
}
}
Animating the Indicator
Finally, let's implement the animation! Remember, that I've said we'll need only one widget and one controller for that. Well, the controller is, unsurprisingly, an AnimationController
. An AnimationController
have a vsync
parameter accepting SingleTickerProviderStateMixin
. To get the SingleTickerProviderStateMixin
we'll need to first convert our widget to a Stateful Widget and add this class in the mix. Then, we'll be able to create the AnimationController
passing this
and a required animation duration
. We'll use 200 milliseconds in our case. Here's what it may look like:
class _TabsIndicatorCaseFinalState extends State<TabsIndicatorCaseFinal> with SingleTickerProviderStateMixin {
late final AnimationController animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
// ...
}
To trigger the animation we'll have to call a method on the animation controller. In our case we want animation to always start from the very beginning (0) and move forward
- up to the end (1). We also need to trigger the animation every time the index controller state changes. To bind the controller together we'll take advantage of the fact that IndexSelectionController
is listenable and listen for the controller events to trigger the navigation. A perfect place to set up the binding would be the initState
method. Here's how we'll do it:
@override
void initState() {
super.initState();
indexController.addListener(() {
animationController.forward(from: 0);
});
}
Now, we'll replace our previously hard-coded progress with the actual animation progress indicated by the animationController.value
:
progress: animationController.value,
And instead of listening to IndexSelectionController
via ValueListenableBuilder
, we'll now listen to state changes of the animation controller. This will be done by utilizing a single widget we'll need for implementing the animation: AnimationBuilder
:
AnimatedBuilder(
animation: animationController,
builder: (context, child) {
// ...
),
Here's what the full code of our animated widget will look like:
class TabsIndicatorCaseFinal extends StatefulWidget {
@override
State<TabsIndicatorCaseFinal> createState() => _TabsIndicatorCaseFinalState();
}
class _TabsIndicatorCaseFinalState extends State<TabsIndicatorCaseFinal> with SingleTickerProviderStateMixin {
late final AnimationController animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
final IndexSelectionController indexController = IndexSelectionController();
@override
void initState() {
super.initState();
indexController.addListener(() {
animationController.forward(from: 0);
});
}
@override
Widget build(BuildContext context) {
return ShowcaseStackCard(
child: AnimatedBuilder(
animation: animationController,
builder: (context, child) {
return RelativelyPositioned(
positionCalculator: (size) => Offset(
calculateMovingLeftOffset(
sourceIndex: indexController.value.previous ?? 0,
targetIndex: indexController.value.current,
progress: animationController.value,
totalWidth: size.width
),
size.height - 3
),
child: Indicator(),
);
}),
onButtonPressed: () => indexController.switchRandom(3)
);
}
}
This is the last lines of code we'll need, but before we'll see the result, let's sum up what we have done in this article!
TLDR;
Using AnimationController
we were able to trigger animation by calling animationController.forward(from: 0);
when a user taps on Slide Under Random Icon
. Then on each AnimatedBuilder
build we calculate currentPosition
based on the current progress indicated by the animation.value
, starting indicator position, stored as tabSelection.previous
and target indicator position, stored as tabSelection.current
. And this is what it looks like:
Keep in mind that this below is a gif, and the animation is much smoother in the real-life
You can check out the full code here. There's also a playground app in the repo. And one more thing... Claps are appreciated! 👏