2

I have used Flutter's CustomPainter class to create a generative still image using Paths (see code below). I'd like to be able to animate such images indefinitely. What is the most straightforward approach to doing this?

import 'package:flutter/material.dart';

void main() => runApp(
      MaterialApp(
        home: PathExample(),
      ),
    );

class PathExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: PathPainter(),
    );
  }
}

class PathPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.grey[200]
      ..style = PaintingStyle.fill
      ..strokeWidth = 0.0;

    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);

    Path path2 = Path();
    for (double i = 0; i < 200; i++) {
      Random r = new Random();
      path2.moveTo(sin(i / 2.14) * 45 + 200, i * 12);
      path2.lineTo(sin(i / 2.14) * 50 + 100, i * 10);
      paint.style = PaintingStyle.stroke;
      paint.color = Colors.red;
      canvas.drawPath(path2, paint);
    }

    Path path = Path();
    paint.color = Colors.blue;
    paint.style = PaintingStyle.stroke;
    for (double i = 0; i < 30; i++) {
      path.moveTo(100, 50);
      // xC, yC, xC, yC, xEnd, yEnd
      path.cubicTo(
          -220, 300, 500, 600 - i * 20, size.width / 2 + 50, size.height - 50);
      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}
RonH
  • 504
  • 1
  • 3
  • 12

1 Answers1

3

To do this, you're going to want to do a lot of what the TickerProviderStateMixin does - essentially, you need to create and manage your own Ticker.

I've done this in a simple builder widget below. It simply schedules a build every time there has been a tick, and then builds with the given value during that ticket. I've added a totalElapsed parameter as well as a sinceLastDraw parameter for convenience but you could easily choose one or the other depending on what's the most convenient for what you're doing.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

void main() => runApp(
      MaterialApp(
        home: PathExample(),
      ),
    );

class PathExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TickerBuilder(builder: (context, sinceLast, total) {
      return CustomPaint(
        painter: PathPainter(total.inMilliseconds / 1000.0),
      );
    });
  }
}

class TickerBuilder extends StatefulWidget {
  
  // this builder function is used to create the widget which does
  // whatever it needs to based on the time which has elapsed or the
  // time since the last build. The former is useful for position-based
  // animations while the latter could be used for velocity-based
  // animations (i.e. oldPosition + (time * velocity) = newPosition).
  final Widget Function(BuildContext context, Duration sinceLastDraw, Duration totalElapsed) builder;

  const TickerBuilder({Key? key, required this.builder}) : super(key: key);
  
  @override
  _TickerBuilderState createState() => _TickerBuilderState();
}  

class _TickerBuilderState extends State<TickerBuilder> {
  
  // creates a ticker which ensures that the onTick function is called every frame
  late final Ticker _ticker = Ticker(onTick);
  
  // the total is the time that has elapsed since the widget was created.
  // It is initially set to zero as no time has elasped when it is first created.
  Duration total = Duration.zero;
  // this last draw time is saved during each draw cycle; this is so that
  // a time between draws can be calculated
  Duration lastDraw = Duration.zero;
  
  void onTick(Duration elapsed) {
    // by calling setState every time this function is called, we're
    // triggering this widget to be rebuilt on every frame.
    // This is where the indefinite animation part comes in!
    setState(() {
      total = elapsed;
    });
  }
  
  @override
  void initState() {
    super.initState();
    _ticker.start();
  }
    
    @override
  void didChangeDependencies() {
    _ticker.muted = !TickerMode.of(context);
    super.didChangeDependencies();
  }
  
  @override
  Widget build(BuildContext context) {
    final result = widget.builder(context, total - lastDraw , total);
    lastDraw = total;
    return result;
  }
  
  @override
  void dispose() {
    _ticker.stop();
    super.dispose();
  }
}
  
class PathPainter extends CustomPainter {
  final double pos;

  PathPainter(this.pos);

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.grey
      ..style = PaintingStyle.fill
      ..strokeWidth = 0.0;

    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);

    Path path2 = Path();
    for (double i = 0; i < 200; i++) {
      Random r = new Random();
      path2.moveTo(sin(i / 2.14 + pos) * 45 + 200, (i * 12));
      path2.lineTo(sin(i / 2.14 + pos) * 50 + 100, (i * 10));
      paint.style = PaintingStyle.stroke;
      paint.color = Colors.red;
      canvas.drawPath(path2, paint);
    }

    Path path = Path();
    paint.color = Colors.blue;
    paint.style = PaintingStyle.stroke;
    for (double i = 0; i < 30; i++) {
      path.moveTo(100, 50);
      // xC, yC, xC, yC, xEnd, yEnd
      path.cubicTo(
        -220,
        300,
        500,
        600 - i * 20,
        size.width / 2 + 50,
        size.height - 50,
      );
      canvas.drawPath(path, paint);
    }
  }

  // in this particular case, this is rather redundant as 
  // the animation is happening every single frame. However,
  // in the case where it doesn't need to animate every frame, you
  // should implement it such that it only returns true if it actually
  // needs to redraw, as that way the flutter engine can optimize
  // its drawing and use less processing power & battery.
  @override
  bool shouldRepaint(PathPainter old) => old.pos != pos;
}

A couple things to note - first off, the drawing is hugely sub-optimal in this case. Rather than re-drawing the background every frame, that could be made into a static background using a Container or DecoratedBox. Secondly, the paint objects are being re-created and used every single frame - if these are constant, they could be instantiated once and re-used over and over again.

Also, since WidgetBuilder is going to be running a lot, you're going to want to make sure that you do as little as possible in its build function - you're not going to want to build up a whole widget tree there but rather move it as low as possible in the tree so it only builds things that are actually animating (as I've done in this case).

rmtmckenzie
  • 37,718
  • 9
  • 112
  • 99
  • @mtmmckenzie Thanks for that; however, when I said 'indefinitely', I meant without any repetition. The code you've supplied has a definite duration and repeats (i.e. basically oscillates). What I'm looking for is the equivalent of draw() in either Processing or OpenFrameworks. – RonH Apr 08 '21 at 10:51
  • whoops, missed 'indefinitely' haha. – rmtmckenzie Apr 08 '21 at 13:27
  • @RonH Edited - that should do it =) – rmtmckenzie Apr 08 '21 at 14:04
  • @mtmmckenzie Thanks again. I don't pretend to fully understand how this is working. In particular, it's not clear to me how 'old.pos != pos' is controlling the movement, or why 'total.inMilliseconds / 1000.0' is controlling the speed. In an environment like Processing or OpenFrameworks, I would simply create variables for each of these and increment them directly when needed within the looping draw() function. Is there a more straightforward way of doing this in Flutter? – RonH Apr 09 '21 at 09:46
  • PS: and Duration.zero? – RonH Apr 09 '21 at 09:52
  • 1
    I probably made it slightly more complicated than absolutely needed. But the reason you wouldn't want to just do a loops is that you can't guarantee when/how often the drawing is done in flutter, as opposed to Processing. On a device that supports 120hz, the drawing might happen twice as often as one that supports 60hz, so the animation woudn't be the same speed. I'll add a bit more explanation to the answer as well. – rmtmckenzie Apr 09 '21 at 21:51
  • 1
    Thanks for adding the comments - they've helped clarify things for me. – RonH Apr 10 '21 at 15:23