Burning paper effect

Perhaps you know this effect from the cult series “Bonanza” from television in the 1960s. If you don’t, imagine a sheet of paper burning from the middle to the edges of the viewpoint. This can be used as a splash screen or a screen transition. This is what it looks like:

Flutter burning paper animation
What we want to achieve

The goal

Let’s describe what we want the result to look like so that we can work on the bullet points during the implementation:

  • There is a solid (of a given color) that spans over the screen
  • From the center, a hole is growing that reveals what is underneath
  • The hole’s shape is a polygon shape that grows irregularly
  • A black contour around the edge of the hole indicates the burning or burnt area
  • In order to make the effect more realistic, a gradient around the hole is placed

Implementation

In order to make our burning paper animation configurable, we need to wrap it with a widget. This way, we can individualize the animation using the constructor’s arguments.

class BurningPaper extends StatefulWidget {
  BurningPaper({
    this.color = Colors.white,
    this.duration = const Duration(seconds: 3),
    this.pointAmount = 30
  });

  final Color color;
  final Duration duration;
  final int pointAmount;

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

class _BurningPaperState extends State<BurningPaper> with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation _animation;
  List<double> points;

  @override
  void initState() {
    super.initState();
    // Here we need to initialize everything
  }

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

  @override
  Widget build(BuildContext context) {
    // Here we need to add the painter to the widget tree
    return Container();
  }
}

What we want to make customizable is the color of the paper, the time it takes for the paper to be burnt and the amount of points that are used to describe the hole. The latter influences the shape of the hole. More points lead to more spiky shape.

We already prepared the methods initState() and build() which we are going to fill with our logic in the following.

But before we write the logic for any kind of information, we are going to write the heart of our logic: the painter. This piece of code is responsible for painting the burnt paper being dependent on the current state of the animation and the given parameters.

class BurningPaperPainter extends CustomPainter {
  BurningPaperPainter({
    @required this.color,
    @required this.points,
  });

  Color color;
  List<double> points;

  @override
  void paint(Canvas canvas, Size size) {
    Path hole = Path();
    Path outline = Path();
    Offset center = Offset(size.width / 2, size.height / 2);

    _buildPaths(hole, outline, center);
    _paintPathsOnCanvas(size, outline, hole, canvas);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

We are going to have two paths: the hole itself and the outline which is almost identical with the hole but a little bit bigger and drawn in black.

There are two methods: _buildPaths and _paintPathsOnCanvas the former is responsible for turning the given point list into paths. The latter is used to actually paint them on the canvas.

void _buildPaths(Path innerHole, Path outerHole, Offset center) {
  for (int i = 0; i < points.length; i++) {
    double point = points[i];
    double radians = pi / 180 * (i / points.length * 360);
    double cosine = cos(radians);
    double sinus = sin(radians);

    double xInner = sinus * point;
    double yInner = cosine * point - sinus;

    double outlineWidth = point * 1.02;

    double nxOuter = sinus * (outlineWidth);
    double nyOuter = cosine * (outlineWidth) - sinus;

    if (i == 0) {
      innerHole.moveTo(xInner + center.dx, yInner * -1 + center.dy);
      outerHole.moveTo(nxOuter + center.dx, nyOuter * -1 + center.dy);
    }

    innerHole.lineTo(xInner + center.dx, yInner * -1 + center.dy);
    outerHole.lineTo(nxOuter + center.dx, nyOuter * -1 + center.dy);
  }
}

We iterate over every “point” which actually describes the distance (radius) from the center. With a little bit of trigonometry, we evenly distribute these radians across a hole circle, rotating every point a little bit further until the circle is completed.
In parallel, we draw the same hole with a 2 % higher distance for every point, which creates an outline around the inner hole.

To make our Path start at the right position and not at (0|0), we move to the first point before drawing the first line.

void _paintPathsOnCanvas(Size size, Path hole, Path outline, Canvas canvas) {
  Rect rect = Rect.fromLTWH(0, 0, size.width, size.height);

  Path holePath = Path.combine(
    PathOperation.difference,
    Path()..addRect(rect),
    hole
  );

  Path outlinePath = Path.combine(
      PathOperation.difference,
      hole,
      outline
  );

  Paint shadowPaint = Paint()
    ..maskFilter = MaskFilter.blur(BlurStyle.outer, 32)
    ..color = Color(0xff966400);

  canvas.drawPath(holePath, Paint()..color = color);
  canvas.drawPath(hole, shadowPaint);
  canvas.drawPath(outlinePath, Paint()..color = Colors.black.withOpacity(0.5));
}

Okay, now it’s time to actually draw the paths we just create on canvas. It’s important that before we draw them, we subtract them from a rectangle of the size covering the whole screen. Otherwise, there would be no transparency showing the underlying screen, but instead just a growing polygon in the given solid color.

In order to mimic a half-burnt paper around the actual hole, we draw a shadow-like object around the hole. To achieve this effect, we use a MaskFilter called blur. We need to set the blurStyle to outer because we don’t want it to cover the hole. We then draw the paths in the following order: first the path that has the hole at the center. On top of that the shadow path that mimics the half-burnt area. Last but not least we draw the outline.

Now that we have defined the behavior of the painter, we still need to add it to the widget tree under our BurningPaper widget.

@override
Widget build(BuildContext context) {
  return Container(
    width: double.infinity,
    height: double.infinity,
    child: CustomPaint(
      painter: BurningPaperPainter(
        color: widget.color,
        points: points
      )
    )
  );
}

In order to cover the whole size of our screen, we use a Container with infinite dimensions and put the CustomPaint below this widget in the tree. We forward the color and points from the surrounding widget to the CustomPaint.

Now let’s try it out by inserting our BurningPaper widget at the root node of the widget tree:

@override
Widget build(BuildContext context) {
  return Material(
      child: Stack(
        children: <Widget>[
          Container(
            decoration: BoxDecoration(
                gradient: RadialGradient(
                  colors: [Colors.orange, Colors.orangeAccent]
                )
            ),
            child: Center(
              child: Text(
                "Burning\nPaper\nEffect",
                style: TextStyle(fontSize: 48, color: Colors.white),
                textAlign: TextAlign.center,
              )
            ),
          ),
          IgnorePointer(
              child: BurningPaper(
                //color: Theme.of(context).accentColor
              )
          )
        ]
      )
  );
}

When we try it out, we see nothing but the underlying widget saying “burning paper effect”. Why is that?

It’s because our point array does not have the ability to grow, yet. Let’s go and add this functionality.

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

  points = [for (var i = 0; i < widget.pointAmount; i+=1) 0];

  _controller = AnimationController(
    duration: widget.duration,
    vsync: this,
  );

  _animation = Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: _controller,
      curve: Curves.easeIn,
    ),
  );

  _controller.forward();

  _controller.addListener(() {
    setState(() {
      for(int i = 0; i < points.length; i++) {
        double newRandomPoint = points[i] + Random().nextDouble() * _animation.value * 100;
        points[i] = newRandomPoint + _animation.value / 2;
      }
    });
  });
}

Instead of letting the distance of the points to the center of the whole grow completely randomly, we use a Tween as a base. We use a CurvedAnimation that uses an easeIn curve. Everytime the controller senses a change, we take the last value and add to it the average of a random value and the current value of the tween.
The random value is just a double (between 0.0 and 1.0) multiplied with the current animation value times 100.

Result

Let’s have a look at the result using different colors for the paper:

Flutter burning paper animation red
Animation with red color
Flutter burning paper animation blue
Animation with blue color

Flutter burning paper animation
Animation with white color

Conclusion

With a CustomPainter and a list of Integers that are randomly increment on every animation step, we were able to quickly create the illusion of a hole burning into the center of a sheet of paper. By using Path.combine and the difference of the hole canvas and the hole that is defined by the array of Integers (representing the radius) we were able to achieve the desired result with a manageable number of code lines. The effect is customizable in terms of its color and the duration of the animation.

If you like what you’ve read, feel free to support me:

🥗Buy me a salad

3 thoughts on “Burning paper effect”

  1. This work seems close to another animation that I have been looking for which is the portal opening/closing. Basically splitting apart and image like an opening or closing of sliding doors.

    Reply
    • Ah I see. I’d say your portal idea is even easier to implement because the path that reveals the underlying widget (the “door”) is not a polygon but rather a rectangle that grows horizontally from the center if I understand correctly.

      Reply

Leave a Comment