Update Flutter Animations with different values

In this blog post I will show you how to animate your Widgets with different values. In this blog post, I show you how to animate you CustomPaint Widgets in Flutter. However, in this animations the start and end value of the animation are fixed. The animation value always go from zero to hundred. You can use this approach to compute the next value based on the percentage you are through with the animation. But you can also update the animation, so you do not have to do this yourself!

Drawing the elements

Let’s start by drawing the elements. I am going to draw three circles and a line. The line will be below the circle that is active. For this I will use two different CustomPainters. One to draw the line and one to draw the circle. As I am going to update the line to move to the active circle, the shoullRepaint is true.

They both use the available method on the canvas to draw their figure. For the line, this is the drawLine method, were I can provide the begin and end point of the line. For the circle, this is the drawCircle method, were I provide the center and the radius of the circle.

class LinePainter extends CustomPainter {
  final Offset center;

  LinePainter(this.center);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawLine(
        Offset(center.dx - 40, center.dy), Offset(center.dx + 40, center.dy), createPaintForColor(Colors.grey));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

class CirclePainter extends CustomPainter {
  final Offset center;
  final Color color;

  CirclePainter(this.center, this.color);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(center, 30, createPaintForColor(color));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

To draw these figures in a Widget, I have to use the Stack Widget. In the Stack Widget, I can draw overlapping Widgets in the same place. This allows me to have space for the LinePainter to move to the other circles.

class ExampleWidget extends StatelessWidget {
  final List<CircleModel> _circles = [
    CircleModel(const Offset(50, 100), Colors.red),
    CircleModel(const Offset(150, 100), Colors.blue),
    CircleModel(const Offset(250, 100), Colors.green)
  ];

  ExampleWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    List<Widget> widgets = [];
    widgets.addAll(_circles.map((e) => CircleWrapper(
          center: e.center,
          color: e.color,
        )));
    widgets.add(CustomPaint(
      painter: LinePainter(const Offset(50, 150)),
    ));
    return Container(
      color: Colors.grey[50],
      width: 300,
      height: 250,
      child: Stack(
        children: widgets,
      ),
    );
  }
}

class CircleWrapper extends StatelessWidget {
  final Offset center;
  final Color color;

  const CircleWrapper({Key? key, required this.center, required this.color}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: CirclePainter(center, color),
      child: Container(),
    );
  }
}

This will result in the following Widget.

Detecting the clicks on the circle

I am going to add a Listener Widget above the Stack Widget to listen to different click events. The full list of possible clicks is available in the Flutter docs. I need the onPointerDown, to determine that someone clicked in the container in wich I draw the CustomPaint Widget.

return Listener(
  onPointerDown: (PointerEvent details) {
    handleClick(details);
  },
  child: Container(
    color: Colors.grey[50],
    width: 300,
    height: 250,
    child: Stack(
      children: widgets,
    ),
  ),
);

So how can we detect on which of the circles is clicked? First of all, I need to add a key to each CircleWrapper. The key is used to find the rendered object of that Widget.

class CircleModel {
  final Offset center;
  final Color color;

  final GlobalKey key = GlobalKey();

  CircleModel(this.center, this.color);
}

class CircleWrapper extends StatelessWidget {
  final Offset center;
  final Color color;
  final GlobalKey csKey;

  const CircleWrapper({Key? key, required this.center, required this.color, required this.csKey}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      key: csKey,
      painter: CirclePainter(center, color),
      child: Container(),
    );
  }
}

This is because we have to do one more thing. Our CustomPainter has to implement the hitTest method. The hitTest method has as input an Offset position. We can use the contains method of the path to determine if it is in or outside the circle. We can create a path of the circle with the fromCircle method of the Rect. It expects the center and the radius of the circle, which we already know because we needed them to draw the circle.

class CirclePainter extends CustomPainter {
  final Offset center;
  final Color color;

  CirclePainter(this.center, this.color);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(center, 30, createPaintForColor(color));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;

  @override
  bool hitTest(Offset position) {
    final Path path = Path();
    path.addRect(Rect.fromCircle(center: center, radius: 30));
    return path.contains(position);
  }
}

Now, since I have the keys of the Widgets I can retrieve the RenderBox of the CustomPaint Widgets. I can use this RenderBox to perform a hitTest with the local coordatines. This hitTest will execute the hitTest in the CustomPaint Widget, and thus return true if I click inside on of the circles.

  handleClick(PointerEvent details) {
    final circle = _circles.firstWhereOrNull((element) => isClicked(details, element.key));
  }

  bool isClicked(final PointerEvent details, final GlobalKey key) {
    final result = BoxHitTestResult();
    final RenderObject? circleBox = key.currentContext?.findRenderObject();
    if (circleBox != null && circleBox is RenderBox) {
      Offset localClick = circleBox.globalToLocal(details.position);
      if (circleBox.hitTest(result, position: localClick)) {
        return true;
      }
    }
    return false;
  }

Animating the line

For the last step, there are a few things that need to be done. First, since the Widget will change, the Widget has to be changed from a StatelessWidget, to a StatefulWidget. Furthermore, since the Widgets will be doing animations, I need to mix in the SingleTickerProviderStateMixin extension. This provides a Ticker for the animation. Now I need three variables. The AnimationController which controls the Animation. Here I can define the duration, but also when to stop and start the animation. I also need a Tween. Here I define the beginning and ending of the values I want to animate over. I initialize these with the starting position of the line. Finally, I need the Animation itself, this provides the Widgets with the current value.

class ExampleWidget extends StatefulWidget {
  const ExampleWidget({Key? key}) : super(key: key);

  @override
  _ExampleWidgetState createState() => _ExampleWidgetState();
}

class _ExampleWidgetState extends State<ExampleWidget> with SingleTickerProviderStateMixin {
  late Animation<double> _animation;
  late Tween<double> _tween;
  late AnimationController _animationController;

 @override
  void initState() {
    super.initState();
    _animationController = AnimationController(duration: const Duration(milliseconds: 500), vsync: this);
    _tween = Tween(begin: 50, end: 50);
    _animation = _tween.animate(_animationController)
      ..addListener(() {
        setState(() {});
      });
  }

As the animation changes, the LinePainter can be provided with the place of the animation value. When the animation starts, the animation value will update, which causes the line to move.

@override
  Widget build(BuildContext context) {
    List<Widget> widgets = [];
    widgets.addAll(_circles.map((e) => CircleWrapper(
          center: e.center,
          color: e.color,
          csKey: e.key,
        )));
    widgets.add(CustomPaint(
      painter: LinePainter(Offset(_animation.value, 150)),
    ));
    return Listener(
      onPointerDown: (PointerEvent details) {
        handleClick(details);
      },
      child: Container(
        color: Colors.white,
        width: 300,
        height: 250,
        child: Stack(
          children: widgets,
        ),
      ),
    );
  }

Finally, let’s start the animation. Since, there already is a handleClick method, I can adjust that. When someone clicks on the circle, I can start the animation. I can simply adjust the begin and end points of the Tween. After I update the start and end position of the Tween, I can start the animation.

  handleClick(PointerEvent details) {
    final circle = _circles.firstWhereOrNull((element) => isClicked(details, element.key));
    if (circle != null) {
      _tween.begin = _tween.end;
      _tween.end = circle.center.dx;
      _animationController.reset();
      _animationController.forward();
    }
  }

The code is available here in a Dartpad, so you can play around with the code without having a full setup. Should you still have any questions, comments, suggestions or other remarks feel free to let me know!

Finally, the resulting animation!

Leave a Reply