From paper to Flutter canvas : a generative animation with Dart and Flutter

rx_labz

Erick Ghaumez

Posted on July 9, 2020

From paper to Flutter canvas : a generative animation with Dart and Flutter

origin

A retrospective of a graphic experiment of almost 20 years in 15 steps ( french version )


2002

Someday I started to draw arrows...

origin

a lot of arrows πŸ˜€ ... Volume, motion,...

old_arrows

2004

I colorized them with Photoshop and Illustrator.

arf

La Fabrick breakz

trajectoires

2008

I mostly code Actionscript, Flash then Flex applications. I discover Generative art; I attend to a Joshua Davis talk at FITC 2008 - Amsterdam. I also discover Erik Natzke , and Mister Nicoptère's works... A lot of inspiration!

If Flash was a perfectly fitted tool to generate graphics, I wasn't skilled enough to code my arrows. I tried a bit with shape tweens... Without success.

Flash interpolation

2010

Thoughts on Flash... Flash begins its early retirement.

2015

2015, I'm still using Flex to develop most of my projects, I hadn't found a good enough replacement ( fast, mature ). But forced, constrained and realistic, I start to study other technologies. Javascript... but also Dart ! πŸ₯°
Coming from Actionscript, one of the first interesting new concept I discovered from the JS world, was observables and RXJS, and naturally Dart streams.

It was by trying to apply this concepts to a HTML canvas, that I finally had an idea to generate my arrows.

Finally, the principle was easy : I just needed to transform/map a stream of mouse positions to a list of polygons : cursor => points => polygons => arrows

With its native streams Dart was a perfect candidate for this playground. I had something like this in mind :

window
    ..onMouseMove.map(mouseToPoint).map(pointToPolygon).listen(onNewPolygon);
Enter fullscreen mode Exit fullscreen mode

2016

First version of Algraphr, coded in Vanilla Dart 1.x .

algraphr

4 years later, I'm more than happy of this choice. Dart 1 was already a very smooth web tool. I was surprised how fast I achieved :

  • dynamic svg drawing,
  • "real time" conversion of SVG to bitmap,
  • display this bitmap in a html canvas
  • export it as PNG file

Nothing that could have not be coded in JS, but the Dart experience was delightful. Simple and effective : no dependency, nothing to config πŸ¦„.

Just a word about this vanilla (Dart) web implementation :

  • SVG shapes are drawn when the mouse moves,
  • when the spacebar is pressed, the shapes are "freezed" => the SVG is converted to bitmap,
  • and displayed in a canvas.

Before doing it, I could not even imagine that this kind of process could be so fast ( it helped me a lot for my Flash grieve :) ! )

The Algraphr experiment stopped there for a long time...

2017

I discover Flutter πŸ’™. So many things to explore... In few weeks, I rebuild one of my Flex mobile app, and from then : 🀩 !

2019

Adobe leaves AIR, its integrated Runtime, and announce the final death of Flash browser plugin.

First generative creations from the Flutter community appear :

then Flutter web become a thing,

and finally Flutter Interact...

with, I still don't know how/why, my face in it 🀯...

So it was more than time to play with my old polygonal friends, and to write a Flutter implementation of my arrows generator : Algrafx.

Rewriting it with Flutter was way simpler, and allowed me to easily add more options.

algrafx

2020

Flutter appears in Codepen, with a lot of demos showing Flutter capabilities in browsers.

Of course :D I started to play with an algrafx in Codepen... and some other abstract animations.

So here we are, summer 2020, and it's time to finally code arrows !


We are going to learn how to draw and animate arrows in a Flutter canvas. In doing so, we'll see how to use the canvas, for basic drawing and more advanced techniques such as gradients or blurring.

➑ Custom painting

We'll start by creating a Flutter application with a CustomPaint widget.

CustomPaint gives us the access to the painting layer, et allows us to manipulate the Canvas, and its basic drawing methods : moveTo, lineTo, drawRect, drawCircle...

The control of the canvas is delegated to a CustomPainter, a class we must extend to "paint" our own intructions.

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

 void main() {
   runApp(MaterialApp(
     home: Scaffold(body: Board()),
     debugShowCheckedModeBanner: false,
   ));
 }

 final size = window.physicalSize / window.devicePixelRatio;

 class Board extends StatelessWidget {
   @override
   Widget build(BuildContext context) => CustomPaint(
         size: size,
         painter: Painter(),
       );
 }

 class Painter extends CustomPainter {
   static final fill = Paint()..color = Colors.red;

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

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

Enter fullscreen mode Exit fullscreen mode

Etape 0 : The origin

in the beginning there was... a point

The biggest trips start with a 1st step, here our lines will start with a 1st point, or more exactly a circle, placed in the center of the window.

The drawing on the canvas being fixed, shouldRepaint should returnsfalse.

class Painter extends CustomPainter {
  static const radius = 10.0;

  static final fill = Paint()..color = Colors.red;

  @override
  void paint(Canvas canvas, Size size) {
      canvas.drawCircle(size.center(Offset.zero), radius, fill);
    }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/MWKXONp

step0b


Etape 1 : Follow this mouse

We'll track the cursor positions. To do that we can use a MouseRegion widget.

class Board extends StatefulWidget {
  @override
  _BoardState createState() => _BoardState();
}

class _BoardState extends State<Board> {
  Offset mouse = Offset.zero;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // update the mouse position when mouse moves
        onHover: (details) => setState(() => mouse = details.localPosition),
        // build a CustomPaint to draw at mouse position 
        child: CustomPaint(size: size, painter: Painter(mouse)),
      );
}

Enter fullscreen mode Exit fullscreen mode

The painter will draw at the position of the cursor, and repaint for each new position.

class Painter extends CustomPainter {
  static const radius = 10.0;

  static final fill = Paint()..color = Colors.red;

  final Offset position;

  const Painter(this.position);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(position, radius, fill);
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => position != oldDelegate.position;
}
Enter fullscreen mode Exit fullscreen mode

step1

Stream

The goal is to generate a graphic by transforming an input stream in geometric shapes... so we'll' use a stream and emit the cursor positions.

class _BoardState extends State<Board> {

  // a streamController for cursor positions
  final StreamController<Offset> _streamer = StreamController<Offset>();

  Stream<Offset> get point$ => _streamer.stream;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // add positions to stream
        onHover: (details) => _streamer.add(details.localPosition),
        // rebuild the painter for each position
        child: StreamBuilder<Offset>(
          initialData: Offset.zero,
          stream: point$,
          builder: (context, snapshot) =>
              CustomPaint(size: size, painter: Painter(snapshot.data)),
        ),
      );

  @override
  void dispose() {
    _streamer.close();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/VwedroV


Etape 2 : Tom Thumb

step2

To draw lines we will draw all cursor positions. We'll store the list of all positions and add them all to the stream for each new.

class _BoardState extends State<Board> {
  // all cursor positions
  final List<Offset> _points = [];

  final StreamController<List<Offset>> _streamer =
      StreamController<List<Offset>>();

  Stream<List<Offset>> get _point$ => _streamer.stream;

  @override
  Widget build(BuildContext context) => MouseRegion(
        // add new position to _points and add the new list to the stream
        onHover: (details) => _streamer.add(_points..add(details.localPosition)),
        child: StreamBuilder<List<Offset>>(
          initialData: _points,
          stream: _point$,
          builder: (context, snapshot) =>
              CustomPaint(size: size, painter: Painter(_points)),
        ),
      );
}
Enter fullscreen mode Exit fullscreen mode

Then we can draw them all.

class Painter extends CustomPainter {
  // ...

  final List<Offset> points;

  const Painter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    // draws a circle for each saved positions
    for (final point in points) canvas.drawCircle(point, 10, fill);
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => true;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/NWxzXKG


Etape 3 : The path

step3

Now that we have a list of points, we can connect them.

class Painter extends CustomPainter {
  static final fill = Paint()..color = Colors.red;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Offset> points;

  const Painter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    if (points.isEmpty) return;
    for (final point in points) canvas.drawCircle(point, 2, fill);

    // draw a line between each points
    for (int i = 0; i < points.length - 1; i++) {
      canvas.drawLine(points[i], points[i + 1], stroke);
    }
  }

  @override
  bool shouldRepaint(Painter oldDelegate) => true;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/BajVJNy


Etape 4 : Ephemeral lines

step4

In order not to overload the canvas, we will limit the number of visible points.

const maxPoints = 29;

// ..

class _BoardState extends State<Board> {
  // ..

  @override
    Widget build(BuildContext context) => MouseRegion(
      onHover: (details) =>
          _streamer.add(Board._points..add(details.position)),
      child: StreamBuilder<List<Offset>>(
        initialData: Board._points,
        stream: point$.map(
          // keeps only the 29 last points 
          (points) => points.skip(max(0, points.length - maxPoints)).toList(),
        ),
        builder: (context, snapshot) =>
            CustomPaint(size: size, painter: Painter(snapshot.data)),
      ),
    );

  // ..
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/eYJKyNG


Etape 5 : Moving points

step5

To animate lines, we'll apply a force to points.

For that, rather than handling Offset, we can create aPoint entity, on which we will apply a displacement proportional to the applied force and undergoing a slight acceleration.

/// pseudo gravity (1px vertical)
const force = Offset(0, 1);

// gravity acceleration factor
const acceleration = 1.1;

class Point {

  // position
  final Offset offset;

  // gravity
  final Offset force;

  final bool active;

  const Point(this.offset, this.force, [this.active = true]);

  //apply the force to offset, and acceleration to force
  Point update() => active
      ? Point(offset + force, force * acceleration, offset.dy < size.height)
      : Point(Offset.zero, Offset.zero, false);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Point &&
          runtimeType == other.runtimeType &&
          offset == other.offset &&
          force == other.force &&
          active == other.active;

  @override
  int get hashCode => offset.hashCode ^ force.hashCode ^ active.hashCode;
}
Enter fullscreen mode Exit fullscreen mode

We use a looped animationController to refresh the points positions within a regular time interval ( AnimationController.unbounded ).

To use an animationController, awe must add a mixin to our widget state : SingleTickerProviderStateMixin.

For each tick, we filter the visibles points, and apply their gravity to obtain their new positions.

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  List<Point> _points = [];

  final StreamController<List<Point>> _streamer =
      StreamController<List<Point>>.broadcast()..add(<Point>[]);

  Stream<List<Point>> get _point$ => _streamer.stream;

  @override
  void initState() {
    // start a looped animation and add a listener : `_updatePoints`
    AnimationController.unbounded(vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_updatePoints);

    super.initState();
  }

  @override
  Widget build(BuildContext context) => MouseRegion(
        onHover: (details) =>
            _streamer.add(_points..add(Point(details.position, force))),
        child: StreamBuilder(
          initialData: _points,
          stream: _point$.map(
            (points) => points.skip(max(0, points.length - maxPoints)).toList(),
          ),
          builder: (_, stream) =>
              CustomPaint(size: size, painter: Painter(stream.data)),
        ),
      );

  // update the points and add them to the stream 
  void _updatePoints() {
    _points = _points
        .where((element) => element.active)
        // apply position and force 
        .map((element) => element.update())
        .toList();
    _streamer.add(_points);
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/ZEQRREq


Etape 6 : Points => Segment => Polygon

Now that we have our list of points, we can transform it.

Points to vertical segments

points to vertical segment

First we draw a vertical line around each point.

@override
void paint(Canvas canvas, Size size) {
  for (final point in points) {
    canvas.drawCircle(point.offset, 2, fill);

    // draw a vertical segment 
    canvas.drawLine(
      point.offset - Offset(0, -50),
      point.offset - Offset(0, 50),
      stroke,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/rNxKKxP

step6

Points to parallelogram

step6b

Now let's draw a parallelogram by connecting edges.

We are going to create a class Segment, which will contain twoPoints. The segments will also have a fill and stroke color. The parallelograms will derived from each segments.

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  Segment update() => Segment(
        point1.update(),
        point2.update(),
        strokeColor: strokeColor,
        fillColor: fillColor,
      );
}
Enter fullscreen mode Exit fullscreen mode

So we go from a list of Points to a list ofSegments.

  @override
  void initState() {
    _streamer = StreamController<List<Segment>>()..add(<Segment>[]);
    AnimationController.unbounded(vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_updateSegments);
    super.initState();
  }

  @override
  Widget build(BuildContext context) => MouseRegion(
        // adds a segment to each new cursor position
        onHover: (details) => _addSegment(details.position),
        child: StreamBuilder<List<Segment>>(
          initialData: <Segment>[],
          stream: _segment$,
          builder: (_, stream) =>
              CustomPaint(size: size, painter: Painter(stream.data)),
        ),
      );

  /// adds a segment between the last point of the previous segment and the new position
  void _addSegment(Offset offset) {
    _segments
      ..add(
        Segment(
          _segments.isEmpty ? Point(offset, force) : _segments.last.point2,
          Point(offset, force),
          strokeColor: strokeColor,
          fillColor: fillColor,
        ),
      );
  }

  // filters inactive segments, updates segments and adds them to the stream
  void _updateSegments() {
    _segments = _segments
        .where((element) => element.active)
        .map((element) => element.update())
        .toList();
    _streamer.add(_segments);
  }
Enter fullscreen mode Exit fullscreen mode

Then in the Painter, we determine the edges of the parallelogram from the points of the segment and we connect them.

points to vertical segment

class Painter extends CustomPainter {
  static const radius = 2.0;

  static const offsetTop = Offset(0, -50);

  static const offsetBottom = Offset(0, 50);

  static final fill = Paint()..color = fillColor;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const Painter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;

    for (final segment in segments.where((element) => element.active)) {
      canvas.drawCircle(segment.point1.offset, radius, fill);

      canvas.drawLine(
        segment.point1.offset - offsetTop,
        segment.point1.offset - offsetBottom,
        stroke,
      );
      canvas.drawLine(
        segment.point1.offset - offsetTop,
        segment.point2.offset - offsetTop,
        stroke,
      );
      canvas.drawLine(
        segment.point1.offset - offsetBottom,
        segment.point2.offset - offsetBottom,
        stroke,
      );
      canvas.drawLine(
        segment.point2.offset - offsetTop,
        segment.point2.offset - offsetBottom,
        stroke,
      );
    }

    for (int i = 0; i < segments.length; i++) {
      canvas.drawLine( segments[i].offset1, segments[i].offset2, stroke );
    }
  }

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

Enter fullscreen mode Exit fullscreen mode

A better API

The result is the one we are looking for, but let's simplify the API a bit.

Point.up(double) & Point.down(double)

class Point {
  final Offset offset;
  final Offset force;

  final bool active;

  static const zero = Point(Offset.zero, Offset.zero, false);

  const Point(this.offset, this.force, [this.active = true]);

  Point update() => active
      ? Point(offset + force, force * acceleration, offset.dy < size.height)
      : zero;

  Offset up(double value) => offset + Offset(0, -value);

  Offset down(double value) => offset + Offset(0, value);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Point &&
          runtimeType == other.runtimeType &&
          offset == other.offset &&
          force == other.force &&
          active == other.active;

  @override
  int get hashCode => offset.hashCode ^ force.hashCode ^ active.hashCode;
}
Enter fullscreen mode Exit fullscreen mode

Segment.corners

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  /// returns the corners of the parallelogram corresponding to the segment
  List<Offset> get corners => [
        point1.up(50),
        point2.up(50),
        point2.down(50),
        point1.down(50),
      ];

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  Segment update() {
    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: fillColor,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's redraw the parallelograms using Path.

class Painter extends CustomPainter {
  static const radius = 2.0;

  static final fill = Paint()..color = fillColor;

  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const Painter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;

    for (final segment in segments) {

      // instanciate a path between edges
      final path = Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close();

      // fill
      canvas.drawPath(path, Paint()..color = segment.fillColor);

      // stroke
      canvas.drawPath(
        path,
        Paint()
          ..color = segment.strokeColor
          ..style = PaintingStyle.stroke,
      );

      canvas.drawCircle(segment.offset1, radius, fill);
    }

    for (int i = 0; i < segments.length; i++) {
      canvas.drawLine( segments[i].offset1,segments[i].offset2,stroke );
    }
  }

  @override
  bool shouldRepaint(Painter oldDelegate) =>
      segments.isNotEmpty && !listEquals(segments, oldDelegate.segments);
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/abdKKNg


Etape 7 : Colors

step7b

To animate the color, we will gradually darken the colors applied to each segment. For this we can convert the color to HSLColor and lower the brightness. The use of an extension simplifies the writing of this operation.

extension on Color {
  /// returns the corresponding HSLColor
  HSLColor get hsl => HSLColor.fromColor(this);

  double get lightness => hsl.lightness;

  Color withLightness(double value) =>
      hsl.withLightness(value).toColor();
}

class Segment {
  //..

  Segment update() {
    // darkens the fill color
    final newFillColor = fillColor.lightness > 0
        ? fillColor.withLightness( fillColor.lightness * .98 )
        : fillColor;

    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/oNbyMYG


Etape 8 : Speed and width

The next step will be to vary the thickness of the strip according to the speed of movement of the cursor. The faster the cursor moves, the finer the line.

a. Segment width

For this we will add a thickness width to the segments, and vary it according to the distance between the 2 points. The parallelogram will have the segment thickness.


const segmentMaxWidth = 100.0;

const segmentMinWidth = 2.0;

const segmentMaxLength = 200.0;

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  bool get active => point1.active && point2.active;

  List<Offset> get corners {
    final width = segmentWidth;
    return [
      point1.up(width),
      point2.up(width),
      point2.down(width),
      point1.down(width),
    ];
  }

  /// computes the thickness of the segment as a function of the distance between its points
  double get segmentWidth => max(
        segmentMinWidth,
        segmentMaxWidth -
            (Rect.fromPoints(point1.offset, point2.offset).longestSide /
                    segmentMaxLength) *
                (segmentMaxWidth - segmentMinWidth),
      );

  const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});

  Segment update() {
    final newFillColor = fillColor.lightness > 0
            ? fillColor.withLightness(min(1, fillColor.lightness * .98))
            : fillColor;

    return Segment(
      point1.update(),
      point2.update(),
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/zYraLoK

b. Chaining segments

To "harmonize" the line, we are going to transform the parallelograms into trapezoids. Each trapezoid will have an "inlet" thickness and an outlet thickness.

points to vertical segment

class Segment {
  final Point point1;

  final Point point2;

  final Color strokeColor;

  final Color fillColor;

  Offset get offset1 => point1.offset;

  Offset get offset2 => point2.offset;

  final Segment previous;

  const Segment(
    this.point1,
    this.point2, {
    @required this.previous, // previous segment
    this.strokeColor,
    this.fillColor,
  });

  bool get active => point1.active && point2.active;

  /// returns the corners of the trapezoid based on the thickness 
  /// of the previous segment and that of the segment itself
  List<Offset> get corners {
    final previousWidth =
        previous != null ? previous.segmentWidth : segmentWidth;
    final width = segmentWidth;
    return [
      point1.up(previousWidth),
      point2.up(width),
      point2.down(width),
      point1.down(previousWidth),
    ];
  }

  double get segmentWidth => max(
        segmentMinWidth,
        segmentMaxWidth -
            (Rect.fromPoints(point1.offset, point2.offset).longestSide /
                    segmentMaxLength) *
                (segmentMaxWidth - segmentMinWidth),
      );

  Segment update() {
    final hslColor = HSLColor.fromColor(fillColor);
    final newFillColor = hslColor.lightness > 0
        ? hslColor.withLightness(min(1, hslColor.lightness * .98)).toColor()
        : fillColor;
    return Segment(
      point1.update(),
      point2.update(),
      previous: previous,
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ https://codepen.io/rx-labz/pen/mdVKjOK


Etape 9 : snapshot

For the moment all the polygons disappear, we are now going to "freeze" them.

It could be done manually ( by clic or space ), but it this example we'll freeze them automatically, within a regular time interval.


class Point {
  // ...

  /// point is freezed by cancelling it's force
  Point freeze() => Point(offset, Offset.zero, false);

  // ...
}

class Segment {
  // ...

  /// segments are freezable
  Segment freeze() => Segment(
    point1.freeze(),
    point2.freeze(),
    previous: previous,
    strokeColor: strokeColor,
    fillColor: fillColor,
  );
}

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...

  final List<List<Segment>> _freezedLines = [];
  StreamController<List<List<Segment>>> _freezedStreamer;
  Stream<List<List<Segment>>> get freezedShape$ => _freezedStreamer.stream;

  @override
  void initState() {
    //...

    // freeze the segments within a time interval
    Timer.periodic(Duration(seconds: 2), (timer) {
    final freezables = _segments
      .where((segment) => segment.active)
      .map((segment) => segment.freeze())
      .toList();
    _freezedLines.add([...freezables]);
    _freezedStreamer.add(_freezedLines);
    });

    //...
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

To avoid redrawing the frozen segments more than necessary, we will create a second CustomPaint, a kind of layer, which will be used to draw the freezed polygons, and which will only be refreshed when the list of freezed segments changes.

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...
  @override
  Widget build(BuildContext context) => MouseRegion(
        onHover: (details) => _addSegment(details.position),
        child: Stack(
          children: [
            StreamBuilder<List<List<Segment>>>(
              stream: freezedShape$,
              builder: (context, snapshot) => CustomPaint(
                size: size,
                painter: BackgroundPainter(snapshot.data ?? []),
              ),
            ),
            RepaintBoundary(
              child: StreamBuilder<List<Segment>>(
                initialData: <Segment>[],
                stream: _segment$,
                builder: (_, stream) => CustomPaint(
                    size: size, painter: ForegroundPainter(stream.data)),
              ),
            ),
          ],
        ),
      );
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We now use 2 CustomPainters :

  • ForegroundPainter draws the moving segments
class ForegroundPainter extends CustomPainter {
  static final fill = Paint()..color = fillColor;
  static final stroke = Paint()
    ..color = Colors.grey
    ..style = PaintingStyle.stroke;

  final List<Segment> segments;

  const ForegroundPainter(this.segments);

  @override
  void paint(Canvas canvas, Size size) {
    if (segments.isEmpty) return;
    for (final segment in segments)
      drawSegment(canvas, segment);
  }

  @override
  bool shouldRepaint(ForegroundPainter oldDelegate) =>
      segments.isNotEmpty && !listEquals(segments, oldDelegate.segments);
}

Enter fullscreen mode Exit fullscreen mode
  • BackgroundPainter draws the freezed segments
class BackgroundPainter extends CustomPainter {
  final List<List<Segment>> lines;

  BackgroundPainter(this.lines);

  @override
  void paint(Canvas canvas, Size size) {
    for (final segments in lines) {
      for (final segment in segments) drawSegment(canvas, segment);
    }
  }

  @override
  bool shouldRepaint(BackgroundPainter oldDelegate) =>
      lines.isNotEmpty && !listEquals(lines, oldDelegate.lines);
}

Enter fullscreen mode Exit fullscreen mode

Both painters use drawSegment().

void drawSegment(Canvas canvas, Segment segment) {
  final path = Path()
    ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
    ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
    ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
    ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
    ..close();
  canvas.drawPath(path, Paint()..color = segment.fillColor);
  canvas.drawPath(
    path,
    Paint()
      ..color = segment.strokeColor
      ..style = PaintingStyle.stroke,
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/QWyxBGX


Etape 10 : Autografx

Finally, we can replace the cursor movement tracking by random positions.


// ...

const maxNumLines = 5;

class _BoardState extends State<Board> with SingleTickerProviderStateMixin {
  // ...

  Offset cursor;

  @override
  void initState() {
    cursor = size.bottomRight(Offset.zero) * random.nextDouble();

    _streamer = StreamController<List<Segment>>()..add(<Segment>[]);
    _freezedStreamer = StreamController<List<List<Segment>>>()..add([]);

    Timer.periodic(Duration(seconds: 2), (timer) {
      final freezables = _segments
          .where((element) => element.active)
          .map((element) => element.freeze())
          .toList();
      _freezedLines.add([...freezables]);
      _freezedStreamer.add(_freezedLines);
      if (_freezedLines.length == maxNumLines) _anim.reset();
    });

    _anim = AnimationController.unbounded(
        vsync: this, duration: Duration(seconds: 1))
      ..repeat()
      ..addListener(_onTick);
    super.initState();
  }

  void _onTick() {
    _moveCursor();
    _updateSegments();
  }

  // random moves 
  void _moveCursor() {
    double nextX = (random.nextDouble() * 300) - 150;
    if ((cursor.dx + nextX > size.width) || (cursor.dx + nextX < 0))
      nextX = nextX * -1;

    double nextY = (random.nextDouble() * 300) - 150;
    if (cursor.dy + nextY > size.height || cursor.dy + nextY < 0)
      nextY = nextY * -1;

    cursor = cursor + Offset(nextX, nextY);
    _addSegment(cursor);
  }

  // ...

}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/GRoGXQJ

So much for the principle, but there are still a few finishes missing:

  • add volume and light using ** gradients **
  • add the arrowhead
  • play with ** blur and opacity **
  • interleave the arrows via a pseudo "** z-ordering **"

Etape 11 : Gradients

To add a gradient to the trapezoids, let's decline the color of the segment: a lighter version, and a darker one.

extension on Color {
  // ...

  Color darker(double factor) {
    final hslColor = HSLColor.fromColor(this);
    return hslColor
        .withLightness(max(0, hslColor.lightness * (1 - factor)))
        .toColor();
  }

  Color lighter(double factor) {
    final hslColor = HSLColor.fromColor(this);
    return hslColor
        .withLightness(min(1, hslColor.lightness * (1 + factor)))
        .toColor();
  }
}

Enter fullscreen mode Exit fullscreen mode

Next, let's add a gradient between the light color, the real color and the dark color.

To create a gradient in the canvas, add a shader of type Gradient.


void drawSegment(Canvas canvas, Segment segment) {
  final path = Path()
    ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
    ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
    ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
    ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
    ..close();
  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .3, .8],
      ),
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/zYraJRL


Etape 12 : Arrowheads

The simplest way will be to transform the last segment nto a triangle.

step0b

void drawSegment(Canvas canvas, Segment segment, {bool isLast = false}) {

  final path = isLast
      ? (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[0].dx, segment.corners[0].dy - 25)
        ..lineTo(
          segment.offset2.dx,
          min(segment.corners[1].dy, segment.corners[3].dy) +
              max(segment.corners[1].dy, segment.corners[3].dy) -
              min(segment.corners[1].dy, segment.corners[3].dy),
        )
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy + 25)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close())
      : (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close());

  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .3, .8],
      ),
  );
}

Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/YzwvOem


Etape 13 : Blur and opacity

To soften the paths, we can overlay a blurred version of the polygons. This produces a "glow" effect which, combined with a variation in opacity, can produce an interesting graphic effect.

For this we will use a Paint.maskFilter. The polygons will be gradually blurred.

void drawSegment(
  Canvas canvas,
  Segment segment, {
  bool isLast = false,
  int count,
  int total,
}) {
  final path = isLast
      ? (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[0].dx, segment.corners[0].dy - 25)
        ..lineTo(
          segment.offset2.dx,
          min(segment.corners[1].dy, segment.corners[3].dy) +
              max(segment.corners[1].dy, segment.corners[3].dy) -
              min(segment.corners[1].dy, segment.corners[3].dy),
        )
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy + 25)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close())
      : (Path()
        ..moveTo(segment.corners[0].dx, segment.corners[0].dy)
        ..lineTo(segment.corners[1].dx, segment.corners[1].dy)
        ..lineTo(segment.corners[2].dx, segment.corners[2].dy)
        ..lineTo(segment.corners[3].dx, segment.corners[3].dy)
        ..close());

  // normal shape
  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor
              .lighter(darkerFactor) ,
          segment.fillColor ,
          segment.fillColor
              .darker(darkerFactor) ,
        ],
        [.0, .3, .8],
      ),
  );

  // blurred shape
  canvas.drawPath(
    path,
    Paint()
      ..shader = ui.Gradient.linear(
        segment.corners[0],
        segment.corners[2],
        [
          segment.fillColor.lighter(darkerFactor).withOpacity(globalOpacity),
          segment.fillColor.withOpacity(globalOpacity),
          segment.fillColor.darker(darkerFactor).withOpacity(globalOpacity),
        ],
        [.0, .2, .8],
      )
      // apply blur
      ..maskFilter = MaskFilter.blur(
          BlurStyle.normal, (total - count) / total * blurFactor),
  );
}

Enter fullscreen mode Exit fullscreen mode

For the opacity, we change the fill color when updating the segments.

class Segment{
  // ...

  Segment update() {
    final newFillColor = fillColor
        .withLightness(max(0.05, fillColor.lightness * lightnessFactor))
        // apply a transparency factor
        .withOpacity(fillColor.opacity * opacityFactor);
    return Segment(
      point1.update(),
      point2.update(),
      previous: previous,
      strokeColor: strokeColor,
      fillColor: newFillColor,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/MWKXqVr


Etape 14 : Pseudo Z order

For this last step, the goal is to intermingle the successive polygons.

For this we will reorder the frozen segments according to their opacity.

class BackgroundPainter extends CustomPainter {
  final List<List<Segment>> lines;

  BackgroundPainter(this.lines);

  @override
  void paint(Canvas canvas, Size size) {
    final allSegments = <Segment>[];

    // all segments 
    for (final segments in lines) {
      for (final segment in segments) {
        allSegments.add(segment == segments.last ? segment.lastified : segment);
      }
    }

    // opacity sort
    allSegments.sort((s1, s2) {
      if (s1.opacity > s2.opacity) return 1;
      if (s1.opacity < s2.opacity) return -1;
      return 0;
    });

    int count = 0;
    for (final segment in allSegments) {
      drawSegment(
        canvas,
        segment,
        isLast: segment.isLast,
        count: count,
        total: allSegments.length,
      );
      count++;
    }
  }

  @override
  bool shouldRepaint(BackgroundPainter oldDelegate) => true;
}
Enter fullscreen mode Exit fullscreen mode

πŸ‘€ codepen.io/rx-labz/pen/JjGZaLw


And There you go ! I hope you learned somethings about CustomPaint.

From there, it's up to you !

πŸ’– πŸ’ͺ πŸ™… 🚩
rx_labz
Erick Ghaumez

Posted on July 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related