TL;DR where is the code? - here

Hey everyone! 👋

Sorry for being lazy but I’m back (at least for now 😉).

Ok back to Flutter and Animations, two of my favourite things in frontend development.

Last year when I was still interning at a company I was asked to build the above animation. It was an interesting but a bit difficult task as the animation had a lot of moving parts and the data inside (amount) will change, hence I cannot use a video or something like that and video would take more space than code. Let me take you through my thought process of building this.

Whenever I think about building an animation I look at this amazing chart by the Flutter team.

Which is better approach for an animation in Flutter

By looking at this chart you can see that our animation is basically lines and nodes. So it looks like a drawing and I thought it’s easy to code it (boy I was so wrong 🥲). Hence I picked CustomPainter.

Before I go ahead I would like to mention that you can also create a RenderObject in Flutter if you want any of the element to be clickable or have elements which are complex to build with CustomPainter but easier using existing widgets.

Moving on to the how I built this animation -

  1. Build the static components without any animations.
  2. Divide the animation is parts and animate them.
  3. Optimize the animation.

Create static components

When I saw the end state of animation I divided it in 3 sections to focus on.

  1. Red - Main node
  2. Blue - edge lines
  3. Yellow - Children nodes

Now that we thought about how we are going to split our components, now we can start creating static components. We should also keep in mind that we need to animate these components so just ensure than you use a variables such that it becomes easier to animate it later.

// Some constants
const _roundedRectangleRadius = 25.0;
const _padding = 15.0;

const Color darkGreen = Color(0xff1B3C37),
    green = Color(0xff01C36D),
    text = Color(0xffEBECEC),
    primary = Color(0xff2A2136);

// The CustomPainter (where Flutter devs are artists)
class TreeSplitAnimationPainter extends CustomPainter {

  // Getters for paints
  Paint get darkGreenPaint => Paint()
    ..color = darkGreen
    ..strokeWidth = 5
    ..style = PaintingStyle.stroke
    ..strokeCap = StrokeCap.round;

  Paint get greenPaint => Paint()
    ..color = green
    ..strokeWidth = 5
    ..style = PaintingStyle.stroke
    ..strokeCap = StrokeCap.round;

  // Here we need to write our code which will draw stuff on canvas
  @override
  void paint(Canvas canvas, Size size) {
    // Sizes of outlines and nodes
    final rootNodeRadius = size.width / 10;
    final outlineRootNodeRadius = rootNodeRadius + 18;
    final nodeRadius = size.width / 12;
    final outlineNodeRadius = nodeRadius + 15;

    // Painting starts here
  }
}

After setting up stuff we can move on to the more interesting part, that is painting on canvas.

Let’s start with making the nodes as it’s easier and canvas has built in methods for them. We will use Canvas API a lot after this.

Making nodes

Main nodeChildren nodes

When you look at them you can see a filled cicle and an outlined circle. Ignore the connecting edge lines we will get back to it in next section.

For the circles we can use Canvas.drawCircle by giving it the center, radius and style.

canvas.drawCircle(center, nodeRadius, circlePaint);

Now we can either draw the outline using the same drawCircle method and set it to not fill the colors but we need to animate it hence we will use another method called Canvas.drawArc

canvas.drawArc(
  Rect.fromCircle(center: center, radius: radius), // creates a box in which the circle is formed
  startAngle,         // starting point of arc
  sweepAngle,         // end point of arc
  false,              // `useCenter` = false we don't want to start and end at center
  paint,              // painting instructions
);

Don’t get scared this feels easier when you start actually doing it.

Now you can use these and make a function to build your node!

void _paintNode({
  required Canvas canvas,             // thing that will paint
  required Offset center,             // obvi
  required double nodeRadius,         // obvi
  required double outlineNodeRadius,  // obvi
  required double startAngle,         // start angle of the arc
  required Paint circlePaint,         // instructions to paint your circle
  required Paint outlinePaint,        // outline painting instructions
}) {
  canvas.drawCircle(center, nodeRadius, circlePaint);

  _paintOutlineCircle(
    canvas: canvas,
    startAngle: startAngle,
    sweepAngle: math.pi * 2,
    paint: outlinePaint,
    radius: outlineNodeRadius,
    center: center,
  );
}

We will place our nodes later but it will work just perfectly fine. (trust me bro 🙃)

Making edge lines

Edges

Now we are at the most difficult yet interesting section. To create we can use Canvas.drawLine for straight lines or Canvas.drawPath to define path and then ask it to draw, basically have a pen and draw on canvas.

By looking at the curves we now that we have to use drawPath method.

canvas.drawPath(path, paint);

It simply takes path and paint as input but defining path is most complicated.

By looking at the lines we can see that it starts at the same point and then splits in middle then moves in either directions and then ends. We can conclude that we need start, and end points and derive middle start and middle end point from them.

mid       =   YofStart + (distance between start and end along Y-axis) / 2
midStart  =   (XofStart, mid)
midEnd    =   (XofEnd, mid)

This roughly translates to

// calculate the point where the line would split
final mid = edgeStart.dy + ((edgeEnd.dy - edgeStart.dy) / 2);
// point where it start to move left/right
final edgeMidStart = Offset(edgeStart.dx, mid);
// point where it start to move down again
final edgeMidEnd = Offset(edgeEnd.dx, mid);

Ahh… we can finally create our lines as we have everything we need, now we can jump into Path APIs. Think it as we are drawing with a pen.

We need to move our pen to the starting point

path.moveTo(edgeStart.dx, edgeStart.dy)

Then move to the mid start point while drawing a line

path.lineTo(edgeMidStart.dx, edgeMidStart.dy)

Then draw till the mid end point

path.lineTo(edgeMidEnd.dx, edgeMidEnd.dy)

Then finally we can end at end point

path.lineTo(edgeEnd.dx, edgeEnd.dy)

Looks simple enough? You can write it using cascade operator in Dart.

final edgePath = Path()
  // start
  ..moveTo(edgeStart.dx, edgeStart.dy)
  // to mid start
  ..lineTo(edgeMidStart.dx, edgeMidStart.dy)
  // to mid end
  ..lineTo(edgeMidEnd.dx, edgeMidEnd.dy)
  // to end
  ..lineTo(edgeEnd.dx, edgeEnd.dy);

Now it looks more readable as well and we have something that looks like this…

Edge lines without curves

Looks good but we can do better, lets add those curves.

Whenever you see a curve you can draw them with a Bézier curve. For this case we are going to use Quadratic Bézier curves.

Quadratic Bézier curves

Quadratic Bézier curves

Flutter has Path.quadraticBezierTo which take two co-ordinate points - control and end point. It starts with the last point to the end point controlled by a control point.

So the plan it to start a bit before the actual line ends and start before the actual line starts.

Edge lines with point of curve dots

We can see that we need to -

  • Start curve 1 before just before we reach mid start and end it before we reach mid end
  • Start curve 2 before just before we reach mid end and end it just after mid end

Our code should look like -

final edgePath = Path()
  // start
  ..moveTo(edgeStart.dx, edgeStart.dy)
  // to mid start
  ..lineTo(edgeMidStart.dx, edgeMidStart.dy)  // early end this
  // to mid end
  // *curve 1 here*
  ..lineTo(edgeMidEnd.dx, edgeMidEnd.dy)      // late start and early end this
  // to end
  // *curve 2 here*
  ..lineTo(edgeEnd.dx, edgeEnd.dy);           // late start this

In flutter quadraticBezierTo takes 4 parameters.

path.quadraticBezierTo(
  controlX,
  controlY,
  endX,
  endY,
)

I know, I also hate this API.

final edgePath = Path()
  ..moveTo(edgeStart.dx, edgeStart.dy)
  ..lineTo(
    edgeMidStart.dx,
    edgeMidStart.dy - _roundedRectangleRadius,  // early end
  )
  ..quadraticBezierTo(
    edgeMidStart.dx,
    edgeMidStart.dy,
    edgeMidStart.dx + _roundedRectangleRadius,  // late start
    edgeMidStart.dy,
  )
  ..lineTo(
    edgeMidEnd.dx -_roundedRectangleRadius,     // early end
    edgeMidEnd.dy,
  )
  ..quadraticBezierTo(
    edgeMidEnd.dx,
    edgeMidEnd.dy,
    edgeMidEnd.dx,
    edgeMidEnd.dy + _roundedRectangleRadius,    // late start
  )
  ..lineTo(
    edgeEnd.dx,
    edgeEnd.dy,
  );

This will give you just the right side to get left side you just need to modify a few things.

final edgePath = Path()
...
  ..quadraticBezierTo(
    edgeMidStart.dx,
    edgeMidStart.dy,
+   edgeMidStart.dx + (isLeft ? -_roundedRectangleRadius : _roundedRectangleRadius),
-   edgeMidStart.dx + _roundedRectangleRadius,
    edgeMidStart.dy,
  )
  ..lineTo(
+   edgeMidEnd.dx - (isLeft ? _roundedRectangleRadius : -_roundedRectangleRadius),
-   edgeMidEnd.dx -_roundedRectangleRadius,
    edgeMidEnd.dy,
  )
...

Finally we can paint it using our drawPath method on Path.

canvas.drawPath(edgePath, greenPaint);

Creating a method for this would look like -

void _paintEdge({
  required Canvas canvas,
  required Offset edgeStart,
  required Offset edgeEnd,
  bool isLeft = false,
}) {
  // calculate the point where the line would split
  final mid = edgeStart.dy + ((edgeEnd.dy - edgeStart.dy) / 2);
  // point where it start to move left/right
  final edgeMidStart = Offset(edgeStart.dx, mid);
  // point where it start to move down again
  final edgeMidEnd = Offset(edgeEnd.dx, mid);

  final edgePath = Path()
    // Move to the starting point
    ..moveTo(edgeStart.dx, edgeStart.dy)
    // Make a straight line to the mid leaving space for curve
    ..lineTo(
      edgeMidStart.dx,
      edgeMidStart.dy - _roundedRectangleRadius,
    )
    // the curve
    ..quadraticBezierTo(
      edgeMidStart.dx,
      edgeMidStart.dy,
      edgeMidStart.dx + (isLeft ? -_roundedRectangleRadius : _roundedRectangleRadius),
      edgeMidStart.dy,
    )
    // Line which moves horizontally again leaving space for curve
    ..lineTo(
      edgeMidEnd.dx + (isLeft ? _roundedRectangleRadius : -_roundedRectangleRadius),
      edgeMidEnd.dy,
    )
    // Another curve
    ..quadraticBezierTo(
      edgeMidEnd.dx,
      edgeMidEnd.dy,
      edgeMidEnd.dx,
      edgeMidEnd.dy + _roundedRectangleRadius,
    )
    // Stretch line to the end
    ..lineTo(
      edgeEnd.dx,
      edgeEnd.dy,
    );

  // Paint the base dark green color
  canvas.drawPath(edgePath, darkGreenPaint);
}

Placing the components

For simplicity of code I’m moving the canvas center from top left to top center by the Canvas.translate

Let’s take our main node as the reference point to so that things makes sense.

It will be a bit below the top center hence the following code -

@override
void paint(Canvas canvas, Size size) {
  ...

  // Center canvas horizontally as it becomes easier to make all measurements
  canvas.translate(size.width / 2, 0);

  // Point where you want the root node to be at end (top-center)
  final start = Offset(0, outlineRootNodeRadius);

  _paintNode(
    canvas: canvas,
    center: start,
    nodeRadius: rootNodeRadius,
    outlineNodeRadius: outlineRootNodeRadius,
    startAngle: math.pi / 2,
    sweepAngle: math.pi * 2,
    circlePaint: Paint()..color = green,
    initialOutlinePaint: darkGreenPaint..color = darkGreen,
    sweepOutlinePaint: greenPaint..color = green,
  );

  ...
}
@override
void paint(Canvas canvas, Size size) {
  ...

  // Start point of edge
  final edgeStart = start + Offset(0, outlineRootNodeRadius);

  // Note: we will calculate only for right node and for left we will negate it

  // end point of the edge
  final edgeEnd = edgeStart +
      Offset(
        // adding padding to have distance between 2 children
        outlineNodeRadius + _padding,
        // how much space child will need from the bottom
        size.height - (outlineNodeRadius * 2) - edgeStart.dy - _padding,
      );

  // position of child node
  final childNode = edgeEnd + Offset(0, outlineNodeRadius);

  // Right edge
  _paintEdge(
    canvas: canvas,
    edgeStart: edgeStart,
    edgeEnd: edgeEnd,
  );
  _paintNode(
    canvas: canvas,
    center: childNode,
    nodeRadius: nodeRadius,
    outlineNodeRadius: outlineNodeRadius,
    startAngle: -math.pi / 2,
    sweepAngle: math.pi * 2,
    circlePaint: Paint()..color = primary,
    initialOutlinePaint: darkGreenPaint,
    sweepOutlinePaint: greenPaint,
  );

  // Left edge
  _paintEdge(
    canvas: canvas,
    edgeStart: edgeStart,
    edgeEnd: edgeEnd.scale(-1, 1),          // negate on x-axis
    isLeft: true,
  );
  _paintNode(
    canvas: canvas,
    center: childNode.scale(-1, 1),         // negate on x-axis
    nodeRadius: nodeRadius,
    outlineNodeRadius: outlineNodeRadius,
    startAngle: -math.pi / 2,
    sweepAngle: math.pi * 2,
    circlePaint: Paint()..color = primary,
    initialOutlinePaint: darkGreenPaint,
    sweepOutlinePaint: greenPaint,
  );
}

Finally after sooooo much efforts we are able to create a static painting on our canvas.

Now we can move to the more difficult part that is animating it.

Divide the animation is parts and animate them

While having working on animating the components we need to look at the bigger picture and think in terms of when the animation starts and when it ends and how it is connected to each other.

Before we write code it’s always better to plan how we are going to do it, as it will make it a lot easier for you to code it and more importantly understand it.

You can divide when the direction of motion, color, or focus (Object which is animating) of animation changes.

Trust your gut feeling.

Tracking Main node’s motion

We just need move the center of our main node from center of the screen to the main position where it should be

@override
void paint(Canvas canvas, Size size) {
  ...

  // Point where you want the root node to be at end (top-center)
  final start = Offset(0, outlineRootNodeRadius);

  // Get center of the screen
  final center = Offset(0, size.height / 2);

  // Paint main node
  _paintNode(
    canvas: canvas,
    center: lerpOffset(center, start, moveRootToTop.value),
    nodeRadius: rootNodeRadius,
    ...
  );

  ...
}

lerpOffset will move our node from center to start while we can control the percent of movement with moveRootToTop.value

edge lines appear

The edge lines appear and shifts attention of the user from main head to it. It’s very easy, just change opacity from 0 to 1.

For this we will just update our darkGreenPaint getter to have color with an opacity to it.

Paint get darkGreenPaint => Paint()
  ..color = darkGreen.withOpacity(fadeInEdge.value)
  ..strokeWidth = 5
  ..style = PaintingStyle.stroke
  ..strokeCap = StrokeCap.round;

We can control the fade in of this with fadeInEdge.value.

Green color flows from main node to edge lines

We can see that the color from the outline of main node decreases, to make this we can use sweepAngle and decrease it. Before that to make it easier we can have green color outline stacked on dark green color outline by updating our _paintNode method.

void _paintNode({
  ...
  required double startAngle,
  required double sweepAngle,
  required Paint circlePaint,
  required Paint initialOutlinePaint,
  required Paint sweepOutlinePaint,
}) {
  ...

  _paintOutlineCircle(
   ...
    paint: initialOutlinePaint,
  );

  _paintOutlineCircle(
    canvas: canvas,
    startAngle: startAngle,
    sweepAngle: sweepAngle,
    paint: sweepOutlinePaint,
    radius: outlineNodeRadius,
    center: center,
  );
}

Things changed here are that we have sweep angle and 2 paint for outline - initial and sweep

For main node the sweep angle decreases, (i.e. 2π to 0) by changing the calling function

_paintNode(
  ...
  startAngle: math.pi / 2,
  sweepAngle: lerpDouble(math.pi * 2, 0, edgeFill.value)!,
  initialOutlinePaint: darkGreenPaint..color = darkGreen,
  sweepOutlinePaint: greenPaint..color = green,
);

lerpDouble(math.pi * 2, 0, edgeFill.value) will move from 2π to 0 controlled by edgeFill.value.

While it’s decreasing we also need to fill the edge lines. Animating lines and curves but I found a neat trick to do this on stackoverflow by andras.

Over here they divided the given path into segments and add it to the path till the specified percentage is reached.

We can use createAnimatedPath function to get the desired animation. Again we will stack it on the dark green color.

void _paintEdge({
  ...
}) {
  ...
  // Paint the base dark green color
  canvas.drawPath(edgePath, darkGreenPaint);

  if (edgeFill.value != 0)
    // Paint the green color which comes in 3rd section
    canvas.drawPath(
      createAnimatedPath(edgePath, edgeFill.value),
      greenPaint,
    );
}

edgeFill.value here increases the value from 0 to 1 increasing the color filled in edge lines.

Noticed that the whole animation is controlled by edgeFill.value, it decreased the value for main node outline while increased the color in edge lines.

Green color flows from edge lines to children

This is exact opposite of what we did in previous step. The edge lines decrease and children node outline increases.

Like the question ‘Is the glass half empty or half full?’ we can say that the edge lines are filling with dark green color instead of draining dark green color as it’s a bit difficult to handle.

We will stack filling dark green color lines on the existing stack

void _paintEdge({
  ...
}) {
  ...

  if (childrenFill.value != 0)
    // Paint it again dark green for final section
    canvas.drawPath(
      createAnimatedPath(edgePath, lerpDouble(0, 1, childrenFill.value)!),
      darkGreenPaint,
    );
}

This is handled by childrenFill.value and we will use the same thing for all the animations in this section.

To fill the outline of children nodes we will increase the sweepAngle from 0 to 2π and fade in the circles at the same time.

_paintNode(
  ...
  startAngle: -math.pi / 2,
  sweepAngle: lerpDouble(0, 2 * math.pi, childrenFill.value)!,
  circlePaint: Paint()..color = primary.withOpacity(childrenFill.value),
  initialOutlinePaint: darkGreenPaint,
  sweepOutlinePaint: greenPaint,
);

Orchestrating these animations

You might think what are these moveRootToTop, fadeInEdge, edgeFill, and childrenFill? These are technically variables with Animation<double> type, I use them to orchestrate the animation. AnimationController while controls the animation, we can attach the controller to multiple Animation<T> to have finer control. Using CurvedAnimation to have different Curves and Interval to define the interval in which the Animation should start and end, for example - start at 20% and end at 60% of animation duration will be written as Interval(0.2, 0.6). Finally all this will be packed in a Tween where you will define the start and end value of the animation.

This will look like -

animation = Tween<double>(begin: 0, end: 1).animate(
  CurvedAnimation(
    parent: animationController,
    curve: const Interval(0.1, 0.2, curve: Curves.easeInOut),
  ),
);

I read it from inside out - start the animation at 10% and end it at 20% following ease-in-out curve, it will be controlled by the animationController and the value will move from 0 to 1.

Coming back to our animation -

You can define it as late variable and then assign it in the contructor of the class.

late final Animation<double> edgeFill,
    fadeInEdge,
    fadeInChildren,
    moveRootToTop;

TreeSplitAnimationPainter({
  required AnimationController animationController,
}) {
  // Tween that moved the root node up as soon as animation starts
  moveRootToTop = Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: animationController,
      curve: const Interval(0, 0.2, curve: Curves.easeInOut),
    ),
  );
  // Shows the dark green colored edges
  fadeInEdge = Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: animationController,
      curve: const Interval(0.2, 0.3, curve: Curves.easeInOut),
    ),
  );
  // Fills the dark green colored edges with green color
  // and removes green from the outline of root node
  edgeFill = Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: animationController,
      curve: const Interval(0.3, 0.7, curve: Curves.easeInOut),
    ),
  );
  // Show the children nodes inside the outline
  fadeInChildren = Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: animationController,
      curve: const Interval(0.7, 1, curve: Curves.easeInOut),
    ),
  );
}

Finally we can use this custom painter in our app!! But before that I would like to suggest you some of the optimizations for the app.

Optimize the animation

Optimizing the canvas animations is not that difficult you just need to be away what are you doing and anything you do may or may not have impact on memory, so beware do it only if you need it.

Premature optimization is the root of evil.


Pass your AnimationController to the repaint property in the CustomPainter constructor.

class TreeSplitAnimationPainter extends CustomPainter {
  TreeSplitAnimationPainter({
    required AnimationController animationController,
  }) : super(repaint: animationController);             // Pass the controller here

  ...
}

Never do set shouldRepaint to true

class Sky extends CustomPainter {
  Sky(this.color);

  final Color color;

  @override
  void paint(canvas, size) {
    ...
  }

  // Don't do this
  @override
  bool shouldRepaint(Sky oldDelegate) => true;
}

Always specify shouldRepaint to react to changes.

class Sky extends CustomPainter {
  Sky(this.color);

  final Color color;

  @override
  void paint(canvas, size) {
    ...
  }

  // Do do this
  @override
  bool shouldRepaint(Sky oldDelegate) => color != oldDelegate.color;
}

Set isComplex to true in CustomPaint widget

CustomPaint(
  ...
  isComplex: true,
),

This will cache images of the CustomPaint.


If an animation causes random rebuilds of your screen you can use RepaintBoundary widget. It separates it’s children from the parent while repainting.

RepaintBoundary(
  child: ...
)

Code and Thanks note

You can go to this site to play around with the code and test the code yourself.

Thanks a lot for reading this long blog! I really enjoy working with animations and CustomPainter in flutter, do connect with me over my socials if you want to have a chat or need any help.