DEV Community

Prashant Singh
Prashant Singh

Posted on • Originally published at Medium

Flying butterfly — Animate a widget along a path in Flutter

While doing animations we might come across cases when we have to animate a widget along a path. Doing that in flutter as a beginner might seem like a daunting and complex task. In this article I've tried to give functions that can be directly used with some modifications to implement such kind of animations. If you're not well aware of the fundamentals of animations in flutter, I suggest checking out my article Building Animations in Flutter — Simplified.

First of all, we need the path to animate along. I will be using a quadratic bezier curve as an example.

Path path = Path();
path.moveTo(300, 200);
path.quadraticBezierTo(450, 0, 700, 80);
Enter fullscreen mode Exit fullscreen mode

The path looks like this (starting from the left):

Quadratic bezier curve path

Now that we have the path we need to add the object we want to move along the path. Also, we need an animation controller to control the movement of the object along the path. We'll now add all those items along with a button to play the animation.

This is what the code will look like after the above changes.

import 'package:flutter/material.dart';

class PathAnimation extends StatefulWidget {
  const PathAnimation({super.key});

  @override
  State<PathAnimation> createState() => _PathAnimationState();
}

class _PathAnimationState extends State<PathAnimation>
    with SingleTickerProviderStateMixin {
  late Path path;
  late AnimationController pathAnimController;
  late Animation<double> pathAnimation;

  @override
  void initState() {
    super.initState();

    createPath();

    pathAnimController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 5000),
    );
    pathAnimation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(parent: pathAnimController, curve: Curves.linear));
  }

  @override
  void dispose() {
    pathAnimController.dispose();
    super.dispose();
  }

  void createPath() {
    path = Path();
    path.moveTo(300, 200);
    path.quadraticBezierTo(450, 0, 700, 80);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: MyPainter(path),
            size: const Size(double.infinity, double.infinity),
          ),
          Positioned(
            right: 5,
            bottom: 5,
            child: FilledButton(
                onPressed: () {
                  pathAnimController.forward(from: 0.0);
                },
                child: const Text("Make it fly")),
          ),
          Positioned(
            left: 300,
            top: 200,
            child: Container(
                width: 10,
                height: 10,
                decoration: const BoxDecoration(
                  color: Colors.red,
                  shape: BoxShape.circle,
                ),
            ),
          )
        ],
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  Path path;

  MyPainter(this.path);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now in the above code there is also present a Tween along with AnimationController so that we can add curve to the animation if needed. There is also now a container present at the start position of the path.

To animate the container along the path we need the positions on the path (from beginning to the end) as input to the Positioned widget that is wrapping the container, I have made a simple function that gives this exact position. It additionally needs another value (a Tween or AnimationController) so that it can interpolate the value along the path and return values for different points on the path. Here's the function I am talking about.

  Offset getPointOnPath(Path path, double animationValue) {
  // This can give us multiple path metrics (Iterable), depending on the nature of the path
    List<PathMetric> pathMetrics = path.computeMetrics().toList();

    PathMetric pathMetric = pathMetrics.elementAt(0);

    // Using the animationValue to get the length of path (from 0 to the actual length)
    double offsetOnPath = pathMetric.length * animationValue;

    // Get the actual position of the point on the path by getting a tanget through that point
    Tangent? pos = pathMetric.getTangentForOffset(offsetOnPath);
    return pos!.position;
  }
Enter fullscreen mode Exit fullscreen mode

We'll get the PathMetrics list from the path (number of path metrics depend on the nature of the path, if we move to a different point using moveTo and then draw another line, we will get two PathMetric in the the list), for our demonstration we are animation along a single continuous path and this is almost always the practical use case.

We are converting the result of computeMetrics to list, because the return value is a 'lazy' Iterable and until we use it, it won't have a value, this can cause issues when trying to add breakpoints for debugging, it would still work fine if we just directly save the return value in 'PathMetrics' instead of List<PathMetric>.

We will call this function for each value of the Tween animation (which runs from 0 to 1) and then we'll use the return value of this function to position the container on the line.

Here's what the code looks like now after integrating the above code.

import 'dart:ui';

import 'package:flutter/material.dart';

class PathAnimation extends StatefulWidget {
  const PathAnimation({super.key});

  @override
  State<PathAnimation> createState() => _PathAnimationState();
}

class _PathAnimationState extends State<PathAnimation>
    with SingleTickerProviderStateMixin {
  late Path path;
  late AnimationController pathAnimController;
  late Animation<double> pathAnimation;

  @override
  void initState() {
    super.initState();

    createPath();

    pathAnimController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 5000),
    );
    pathAnimation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(parent: pathAnimController, curve: Curves.linear));
  }

  @override
  void dispose() {
    pathAnimController.dispose();
    super.dispose();
  }

  void createPath() {
    path = Path();
    path.moveTo(300, 200);
    path.quadraticBezierTo(450, 0, 700, 80);
  }

  Offset getPointOnPath(Path path, double animationValue) {
    // This can give us multiple path metrics (Iterable), depending on the nature of the path
    List<PathMetric> pathMetrics = path.computeMetrics().toList();

    PathMetric pathMetric = pathMetrics.elementAt(0);

    // Using the animationValue to get the length of path (from 0 to the actual length)
    double offsetOnPath = pathMetric.length * animationValue;

    // Get the actual position of the point on the path by getting a tanget through that point
    Tangent? pos = pathMetric.getTangentForOffset(offsetOnPath);
    return pos!.position;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: MyPainter(path),
            size: const Size(double.infinity, double.infinity),
          ),
          Positioned(
            right: 5,
            bottom: 5,
            child: FilledButton(
                onPressed: () {
                  pathAnimController.forward(from: 0.0);
                },
                child: const Text("Make it fly")),
          ),
          AnimatedBuilder(
            animation: pathAnimation,
            builder: (_, Widget? child) {
              Offset pointOnPath = getPointOnPath(path, pathAnimation.value);
              return Positioned(
                left: pointOnPath.dx,
                top: pointOnPath.dy,
                child: child!,
              );
            },
            child: FractionalTranslation(
              translation: const Offset(-0.5, -0.5),
              child: Container(
                width: 10,
                height: 10,
                decoration: const BoxDecoration(
                  color: Colors.red,
                  shape: BoxShape.circle,
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  Path path;

  MyPainter(this.path);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

I've made another slight change to incorporate one of favourite widget 'FractionalTranslation'. If we don't use it in this case, flutter will position the widget using the top left part of the widget, but if we translate the widget fractionally by half to the left and half to top, we will be able to position the centre of the widget at the given position. Beautiful!

Here's the output after the change.

GIF of a circular container moving along a quadratic bezier path

Perfect. Now what about the flying butterfly in the title? That's very easy now.

We'll use a gif for the butterfly and just animate it along the a path. But we don't have to create a boring bezier curve again, we can make our own custom path.

To create our own custom path:

  1. We'll use the Pen tool in Figma to draw the path.
  2. Smoothen out the corners
  3. Export the path as an SVG file
  4. Then use some online path to SVG convertor, to convert the SVG to Flutter path

After we have the above path we'll just replace the path in the code above with the new path, add the butterfly and finally, we have our flying butterfly.

GIF of a butterfly flying along a custom path

And here's the final code.

import 'dart:ui';

import 'package:flutter/material.dart';

class PathAnimation extends StatefulWidget {
  const PathAnimation({super.key});

  @override
  State<PathAnimation> createState() => _PathAnimationState();
}

class _PathAnimationState extends State<PathAnimation>
    with SingleTickerProviderStateMixin {
  late Path path;
  late AnimationController pathAnimController;
  late Animation<double> pathAnimation;

  @override
  void initState() {
    super.initState();

    createPath();

    pathAnimController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 5000),
    );
    pathAnimation = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(parent: pathAnimController, curve: Curves.linear));
  }

  @override
  void dispose() {
    pathAnimController.dispose();
    super.dispose();
  }

  void createPath() {
    Size size = const Size(1000, 500);
    path = Path();
    path.moveTo(size.width * 0.004926108, size.height * 0.9941860);
    path.cubicTo(
        size.width * 0.004926108,
        size.height * 0.9941860,
        size.width * 0.1045823,
        size.height * 0.7659767,
        size.width * 0.2142857,
        size.height * 0.6773256);
    path.cubicTo(
        size.width * 0.3061655,
        size.height * 0.6030756,
        size.width * 0.3939709,
        size.height * 0.6432733,
        size.width * 0.4827586,
        size.height * 0.5639535);
    path.cubicTo(
        size.width * 0.5587044,
        size.height * 0.4961099,
        size.width * 0.5546749,
        size.height * 0.4011593,
        size.width * 0.6330049,
        size.height * 0.3372093);
    path.cubicTo(
        size.width * 0.7010640,
        size.height * 0.2816459,
        size.width * 0.7638473,
        size.height * 0.3074238,
        size.width * 0.8325123,
        size.height * 0.2529070);
    path.cubicTo(
        size.width * 0.9195222,
        size.height * 0.1838262,
        size.width * 0.9975369,
        size.height * 0.002906977,
        size.width * 0.9975369,
        size.height * 0.002906977);
  }

  Offset getPointOnPath(Path path, double animationValue) {
    // This can give us multiple path metrics (Iterable), depending on the nature of the path
    List<PathMetric> pathMetrics = path.computeMetrics().toList();

    PathMetric pathMetric = pathMetrics.elementAt(0);

    // Using the animationValue to get the length of path (from 0 to the actual length)
    double offsetOnPath = pathMetric.length * animationValue;

    // Get the actual position of the point on the path by getting a tanget through that point
    Tangent? pos = pathMetric.getTangentForOffset(offsetOnPath);
    return pos!.position;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: MyPainter(path),
            size: const Size(double.infinity, double.infinity),
          ),
          Positioned(
            right: 5,
            bottom: 5,
            child: FilledButton(
                onPressed: () {
                  pathAnimController.forward(from: 0.0);
                },
                child: const Text("Make it fly")),
          ),
          AnimatedBuilder(
            animation: pathAnimation,
            builder: (_, Widget? child) {
              Offset pointOnPath = getPointOnPath(path, pathAnimation.value);
              return Positioned(
                left: pointOnPath.dx,
                top: pointOnPath.dy,
                child: child!,
              );
            },
            child: FractionalTranslation(
              translation: const Offset(-0.5, -0.5),
              child: Image.asset(
                "assets/images/butterfly.gif",
                width: 70,
                height: 70,
              ),
            ),
          )
        ],
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  Path path;

  MyPainter(this.path);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 0.25;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading. Drop a comment if you have any doubts.

Do follow me if you liked this.

Top comments (0)