← Back to blog

How and when to create custom Implicit Animations

Prateek Sharma

Mobile Engineer 4 at Tide

22nd August, 2023

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Introduction

It was October 2022 when I was preparing for my first-ever talk – Unleash Power of AnimatedPositioned. The main highlight of the talk was to show how a complex animation like forming India Flag from some randomly placed dots can be achieved with just a single widget – AnimatedPositioned.

I came up with many use cases of this idea. But one that stood out was creating a ShapesApp, that would switch between geometrical shapes with animation, like a square to a rectangle or to a rhombus, triangle, etc., that too with dots using AnimatedPositioned. Honestly, I was too obsessed with this widget.

After trying to transition a square to a rectangle with dots, I realized that it didn’t look good. Even though the above animation can be improved with some tweaks, how do I control the animation of dots for other shapes like triangles or kites? I had shortlisted around 50 shapes to be in my app, so you can guess what a mammoth that would’ve been.

But why I’d want to show shapes with animation in the first place? Well, I believe that if a picture can speak 1000 words, then animation can speak 10x than that. Run the app yourself in the below frame and see how easy it is to remember the differences between shapes.

Eventually, I found the most optimal way by creating Custom Implicitly Animation Widget. In this article, I will try to explain every failed approach I took and the shortcomings of each approach.


Animations in general

Animations in Flutter are broadly categorized into two types – Implicit animations and Explicit animations. Simply put, if you animate the widgets using AnimationController and AnimatedBuilder by yourself, that’s an explicit animation. While using implicit animations, you only care about initial and final values that should animate; the rest is hidden from you.

Let’s take a very simple and relatable example of animating a square-shaped box to a rectangle shape. The below code has a main.dart and explicit_animation_screen.dart file.

To achieve this animation using explicit animations, you’ll generally have a Stateful widget, a AnimationController, two Tween<double>s for width and height and AnimatedBuilder. In the above frame, initially, you see a square with a width and height set to 200.

Here’s what happens when you tap the button with the “Rectangle” text:

  1. _controller runs forward over 1 second, producing 60 values from 0.0 to 0.1.
  2. AnimatedBuilder rebuilds the Container for new _width.value 60 times in a second.

And here’s what happens when you tap the button with “Square” text (When the current shape is a rectangle):

  1. _controller runs in a reverse direction for 1 second, producing 60 values from 0.1 to 0.0.
  2. AnimatedBuilder rebuilds the Container for new _width.value 60 times in a second.

What is an Implicit Animation Widget?

An implicit animation widget is a widget of course, that manages the complete animation lifecycle for you.

It takes a set of initial values and a set of final values. Once you start the animation, the initial values change to final values over a duration, and we can visually see the changes. These values can be colour, position, size, or anything else. Let’s see how square-rectangle-square transition can be achieved using one of the already defined Implicit animation widgets.

The below code has a main.dart and implicit_animation_screen.dart file.

The output doesn’t change, but the code size and complexity have changed significantly. You’ll notice that in implicit_animation_screen.dart, there is no AnimationController, Tweens or AnimatedBuilder. There is AnimatedContainer, though, with the duration of 1 second instead of Container. We still have a Stateful widget to change the width value when either button is tapped.

Here’s what happens when you tap the button with the “Rectangle” text:

  1. A new _width value, i.e. 400 is assigned to AnimatedContainer
  2. AnimatedContainer creates a tween under the hood and runs the AnimationController in a forward direction over a 1-second duration.
  3. On value change of AnimationController, setState() is called to rebuild the widget.

Yes, Implicit animation widgets use setState() to rebuild the UI. As all these widgets extend ImplicitlyAnimatedWidget, which is a Stateful widget. The state class ImplicitlyAnimatedWidget extends ImplicitlyAnimatedWidgetState class that attaches the listener to the controller value changes and calls setState() on value change.

Everything remains the same when the “Square” button is tapped, except that this time the new _width value is 200.

I believe you’ve understood how easily we can achieve the same results with the Implicit animation widget because declaring AnimationController, Tweens, and updating UI on every tick is all taken care of by the widget, and we just have to use AnimatedContainer.

We’ve just scratched the surface of how the Implicit animation widget work, but we’ll see more of it in detail under the Anatomy of Implicit Animated widget section.


Matrix4 Transformations

Do we have any implicit animation that can also change its skew properties? Can I also form a rhombus from a square or a parallelogram from a square? That’s my next question when I see that square-rectangle-square is possible.

No. There is no implicit animation for that. But there is Matrix4Tween that we can utilise together with AnimationController. We can also have a Tween<double> for skewX because by changing only skewX, you can make a rhombus from a square.

Refer to the below code:

The above code does the following:

  1. Wraps the AnimatedContainer in AnimatedBuilder.
  2. The AnimatedBuilder builds a Transform widget with a Matrix4 definition.
  3. There is a third button – “Rhombus”, on tap, which _controller runs in a forward direction. This changes the value of _skewX from 0.0 to -0.4.
  4. On tap of the other two buttons, the _controller runs in the reverse direction that brings back the _skewX to 0.0.

Voila! we’re able to transition rhombus from square/rectangle and vice-versa. This looks awesome, but there is a minute mistake introduced here.

Try changing the endvalue of _skewX to -0.8 in matrix4_animation_screen.dart file. What’s the shape? Is it a rhombus with all equal sides? No. Even though you make it work by changing the width and height of the container for the rhombus, the code becomes very complex for just 3 shapes. This clearly was a huge deviation from the ultimate goal of creating 50 shapes. So, what’s the next approach?


CustomPainter

Yes, CustomPainterenables us to access canvas API and draw anything on the screen. So, how do we utilise it to create shapes and animate them from one another? I later realised that I should’ve started with CustomPainter since the start, but nevertheless, I learnt what won’t work, which is good.

With canvas, we can draw a polygon, arcs, custom paths, etc. So, now let’s replicate square-rectangle animation. We’ll get to more shapes soon, but first, achieve the simplest of things before moving to complex shapes. Once the base is ready, it’ll be a cakewalk.

The below diagram explains how offsets will be placed for each of the four vertices of the square and rectangle.

Considering that these shapes are drawn on a coordinate system, the square has each side of length 6 units, whereas the rectangle has the longest side of 10 units in length and shorter of 6 units.

In Flutter, the 4 points that form square and rectangle are Offsets starting from top-left, moving to top-right, bottom-right and bottom-left. To animate the value of these offsets to form a rectangle, we can create 4 Tween<Offset>s. The beginning value for these tweens will resemble square offsets, and the end values will resemble rectangle offsets.

I have taken whole numbers in the above image to make it easy to understand. But, during the actual implementation, we should consider the width and height of the canvas.

Let’s assume the canvas width and height to be 280, so we want the left-top Offset of the square to be Offset(280 * 0.2, 280 * 0.2), which will be Offset(56, 56). In simple terms, just divide the above coordinates by 10 when you code.

Let’s jump into code:

In the custom_paint_animation_screen.dart file, you’ll notice that I have used AnimationController and 4 Tween<Offset>s. The _width and _height resemble the canvas size because we want each shape to be constrained.

Then, in the build(), you’ll see AnimatedBuilder building a CustomPaint.

The CustomPaint uses ShapePainter to draw a path from the given 4 offsets.

In the shape_painter.dart file, you’ll see canvas.drawPoints() which uses PointMode.polygon. Here’s a brief about what each PointMode does:

  1. points places the offsets, doesn’t draw a line between them
  2. lines connects 2 sequential dots and draws lines like offset 1 and offset 2 forms a line, then offset 3 and offset 4 draw another line, and so on.
  3. polygon connects all the points sequentially

The second parameter takes a list of offsets. You’ll notice that offset1 is repeated at the last position because we want to form a closed shape.

The third parameter takes in a Paint to style to the shape. The glow around the lines is because of maskFilter.

When the “Rectangle” button is pressed, the _controller runs forward, and all 4 offset tweens reach their end values to form a rectangle. The exact opposite happens when the “Square” button is pressed.

You might wonder why I am using explicit animation here if there is an implicit animation widget that animates CustomPaint. But, there is no such widget as of now in the SDK. So, all we’ve to deal with is explicit animation. So, the next question is – Can we make and animate Rhombus, Triangle, or any other polygon shape with CustomPainter? The direct answer is YES. But, there are complexities in doing that with explicit animation.


Complexities with CustomPainter and Tweens

Let’s understand the transition of offsets from the below diagram:

We’ve seen how we could animate from square to rectangle with Tween. When we want to animate to a rhombus, we need new tweens or update the older tweens with a new begin value resembling offsets of the current shape(rectangle) and end value resembling offsets of the new shape, i.e. rhombus. Also, a rhombus can be animated back to a square or rectangle so that we will have an initial and final shape at any point in time. Maybe this is confusing while reading, so below is a tabular view of this. For simplicity, this table has the tween value for the bottom-right vertex only while animating square-rectangle, rectangle-rhombus and rhombus-square.

Referring to the arrows – the end value of the square-rectangle transition becomes the beginning value of the rectangle-* transition, where * can be any shape. Deciding and updating the tween as per current and next shape is the first complexity.

The next complexity is deciding when to move the animation controller in a forward or reverse direction. That raises a question how implicit animation widgets are doing this? When we use AnimatedPositioned widget and change its left/right/top/bottom attributes, it easily animates the properties from the current to the next value. Thinking of this excited me to look at Implicit animation widget functionality. Maybe creating my own implicit animation widget is what is needed here for my app. Let’s see how the Implicit animation widget works.


Anatomy of Implicit Animated widget?

Let’s see what AnimatedPositioned widget looks like. First and foremost AnimatedPositioned extends ImplicitlyAnimatedWidget.

class AnimatedPositioned extends ImplicitlyAnimatedWidget {}

The Stateful widget

The ImplicitlyAnimatedWidget extends Stateful widget, which means that there must be a separate State class as well that will manage its state. The ImplicitlyAnimatedWidgetState class is that state class which also uses SingleTickerProviderStateMixin.

abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {}

Well, as soon as you see SingleTickerProviderStateMixin, you can guess that sooner or later, there will be an AnimationController in the class. This covers half of the boilerplate code required to implement explicit animation. The next half covers the Tween declaration and running of the animation controller.

There is also a base abstract class already created that deals with adding a listener to the animation controller and also calling setState(), when the animation controller’s value changes.

abstract class AnimatedWidgetBaseState<T extends ImplicitlyAnimatedWidget> extends ImplicitlyAnimatedWidgetState<T> {
  @override
  void initState() {
    super.initState();
    controller.addListener(_handleAnimationChanged);
  }

  void _handleAnimationChanged() {
    setState(() { /* The animation ticked. Rebuild with new animation value */ });
  }
}

AnimatedPositioned‘s state class should be of this type so that the controller listener and updating UI are also taken care of automatically.

The State class

Let’s return to the AnimatedPositioned widget and refer to the left property, which is of double type. There are other properties as well, but to explain, I will take only one property.

The state class for AnimatedPositioned is _AnimatedPositionedState. It declares a Tween<double> for the left property.

Tween<double>? _left;

Next, there is a callback function forEachTween()that does initialisation and updation of the tween with the help of a TweenVisitor. Remember, this was our first complexity to update the tween for the current and the next value. So, what is this visitor? Let’s decode that.

_left = visitor(
	_left, 
	widget.left, 
	(dynamic value) => Tween<double>(begin: value as double),
) as Tween<double>?;

It takes three parameters:

  1. The _left is the tween itself, it can be the first initial tween or can be the updated tween for 2nd transition from the above table (rectangle-rhombus)
  2. The widget.left is the next value. This is the value that you assign to the left attribute for AnimatedPositioned.
  3. The third parameter is a Tween constructor. It initialises the tween with a value. What is that value? For that, we have to find where this forEachTween() is invoked.

Auto-creating the Tweens

The answer lies in the ImplicitlyAnimatedWidgetState class. This is the same class that uses SingleTickerProviderStateMixin and initialises animation controller. In the initState() of this class, there is a call to _constructTweens().

bool _constructTweens() {
  bool shouldStartAnimation = false;
  forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
		//1
    if (targetValue != null) {
			//2
      tween ??= constructor(targetValue);
			//3
      if (_shouldAnimateTween(tween, targetValue)) {
        shouldStartAnimation = true;
      } else {
				//4
        tween.end ??= tween.begin;
      }
    } else {
      tween = null;
    }
    return tween;
  });
	//5
  return shouldStartAnimation;
}
  1. If left(targetValue) property is unassigned or assigned as null; ideally, nothing should happen.
  2. A fresh tween is created because the left value is non-null. This is what ??= does. It assigns value only if the variable is currently null.
  3. The _shouldAnimateTween() checks if the left tween’s end value or begin value is equal to the targetValue. This will be false as this is the first value assigned to AnimatedPositioned.
  4. The constructor created Tween with only begin value earlier, but here the tween now has end value equal to begin value as well.
  5. Since there was no need to animate, false value is being returned from this function.

Auto-updating the Tweens

This was all about creating the tween under the hood. What happens when you assign a new left value to AnimatedPositioned widget. The didUpdateWidget() is called:

@override
//1
void didUpdateWidget(T oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (widget.curve != oldWidget.curve) {
    _animation.dispose();
    _animation = _createCurve();
  }
  _controller.duration = widget.duration;
	//2
  if (_constructTweens()) {
    forEachTween((Tween<dynamic>? tween, dynamic targetValue, TweenConstructor<dynamic> constructor) {
			//3
      _updateTween(tween, targetValue);
      return tween;
    });
		//4
    _controller
      ..value = 0.0
      ..forward();
    didUpdateTweens();
  }
}

void _updateTween(Tween<dynamic>? tween, dynamic targetValue) {
  if (tween == null) {
    return;
  }
	//3
  tween
    ..begin = tween.evaluate(_animation)
    ..end = targetValue;
}
  1. When there is a configuration change for AnimatedPositioned, like a new left value is assigned then didUpdateWidget() is invoked.
  2. Remember, the _constructTweens() will return true when a new left value is assigned.
  3. The _updateTween(), will evaluate a new begin value and assign it to the tween. The new left value becomes the new end value. This is updating of tweens, which was the first complexity.
  4. The _controller is set to start from 0.0 and move forward, solving our second complexity of running the controller in some direction.

All of this is part of ImplicitlyAnimatedWidget and it’s state class ImplicitlyAnimatedWidgetState. However, there is one more thing that you need to do apart from overriding forEachTween() in your widget:

@override
Widget build(BuildContext context) {
  return Positioned(
    left: _left?.evaluate(animation),
		// ... other properties
    child: widget.child,
  );
}

In the build(), you need to assign a value to AnimatedPositioned‘s left value by evaluating the tween with the current value of the _controller. All in all, if you have a single value for an Animated* widget, you will at minimum need the below widget:

class AnimatedX extends ImplicitlyAnimatedWidget {
  const AnimatedX({
    super.key,
    required super.duration,
    required this.left,
		this.child,
  });

	//1
  final double left;

  @override
  ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() => _AnimatedXState();
}

class _AnimatedPolygonState extends AnimatedWidgetBaseState<AnimatedPolygon> {
	//2
	Tween<double>? _left;
	
	@override
  void forEachTween(TweenVisitor<dynamic> visitor) {
		//2
    _left = visitor(_left, widget.left, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
	}

	@override
  Widget build(BuildContext context) {
    return X(
			//3
			left: _left?.evaluate(animation),
			child: child,
		);
}

Briefly and for simple widgets, if you’re to create an “Animated” version of a widget “X”, you need to follow the below 3 steps:

  1. Define property to animate
  2. Declare and update the tween in forEachTween
  3. Evaluate the latest value and assign it to the widget

Note: The way you evaluate final value differs when you have opacity, alignment, etc. properties. For drawing shapes, I needed only Offsets which work similarly to the left value. You can explore AnimatedOpacity to understand better.

Now that you know how easy it’s to create your own custom implicit animation widget, let’s see how this fits in creating and animating shapes.


AnimatedPolygon

I divided shapes broadly into three categories – polygons, arcs and 3Ds. By the name itself, polygons are shapes drawn just by lines. So, now we need to create an animated widget that can take N number of offsets and draw a closed shape out of it. With the help of ShapePainter, we will be able to draw a polygon path from the offsets.

By using a List<Offset>, we can achieve N-vertices shapes, i.e. triangles, pentagons, hexagons, etc. That’s the cherry on the cake because now we’re not bound to define a fixed number of offsets.

There are only 2 important parts to explain here – forEachTween and build(). In the below frame, you can open the animated_polygon.dart file:

  1. We have a list of offsets, so we need a list of Tween<Offset> as well.
  2. Iterate over the list of offsets
  3. Sometimes it will happen that we change a number of offsets, like when a square is transitioned to a triangle. For those cases, we need to make an already existing tween for that extra offset.
  4. As for the left, we call the visitor for offset tween now.
  5. Assign new tweens to a _temp list rather than _offsetTweens
  6. We shouldn’t be clearing the _offsetTweens ; otherwise, the mapping disappears, and tween evaluation fails. That’s why you should replace old tweens with new ones when dealing with lists.
  7. In the build(), you can again iterate through the _offsetTweens and evaluate each tween as per the current animation value. Assign these new offsets to ShapePainter.

I’ve made a tiny change to ShapePainter from the previous example.

  1. Instead of a fixed amount of offsets, now it takes a list of offsets.
  2. Again, because now we support 3-vertices, 4-vertices, 5-vertices, and so on, we can have some nullable offsets. For that, I only take non-nullable offsets.

And that’s it. 🎉 How was it? Was it easy? Have you understood every bit of this article?

In the above code, I’ve also put a list of offsets for some shapes, you can change them and create any shape with lines. Let’s enhance this idea to draw 3-D shapes.


What about 3Ds?

We won’t achieve 3D shapes with CustomPainter. But, we can use isometric projections to showcase 3D shapes in a 2D plane. In simpler terms, the lines facing you in the app will be of width 5, and the thinner lines that represent lines not visible to us completely will be of width 2.

Extending the idea of AnimatedPolygon, we can this time have 2 lists of offsets – one to draw thicker lines and the other to draw thinner lines. You just have to figure out a closed shape with thinner lines and thicker lines.


What about Arcs?

Well, that’s really complex, to be honest. Polygons are simpler because they can be drawn just with lines. But shapes like circles, ellipsis, arcs, semicircles, parabolas, etc. Remember, the target is to achieve these shapes with animations so one shape should transition to another with relatable change in offsets. Let me explain this more with theory and actual transitions.

For arc-circle-ellipsis-semicircle animation, we just need to change the width of the shape and the sweep angle. For that, we can use path.arcTo(). Sweep angle will give us nice arc-circle and circle-semicircle animation by changing the angle from N to 359 to 180 degrees, like below:

To form an ellipsis, we just need to reduce the circle’s width.

And for parabolas and hyperbolas, we need conicTo curves. Semi-circle is an exception here because it also has a line. For that, I figured out that using conicTo if the start point, control point and end point all lie in a line, then it can form a line.


Summary

Hurray! 🎉 You’ve successfully learnt the working of the Implicit animation widget and also learnt how you can create your own if needed. Here are the key takeaways:

  1. Creating shapes with dots was a bad idea.
  2. Explicit animations give you full control but a lot of boilerplate code.
  3. Custom implicit animations can reduce the overhead of managing tweens and running controllers.
  4. Using conicTo as well as a line can be created.
  5. If an image speaks 1K words, an animation speaks 10X than that.

Stay tuned for more updates and exciting news that we will share in the future. Follow us on Invertase TwitterLinkedin, and Youtube, and subscribe to our monthly newsletter to stay up-to-date. You may also join our Discord to have an instant conversation with us.

Prateek Sharma

Mobile Engineer 4 at Tide

Categories

Tags