From paper to Flutter canvas : a generative animation with Dart and Flutter
Erick Ghaumez
Posted on July 9, 2020
A retrospective of a graphic experiment of almost 20 years in 15 steps ( french version )
2002
Someday I started to draw arrows...
a lot of arrows π ... Volume, motion,...
2004
I colorized them with Photoshop and Illustrator.
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.
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);
2016
First version of Algraphr, coded in Vanilla Dart 1.x .
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.
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;
}
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;
}
π codepen.io/rx-labz/pen/MWKXONp
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)),
);
}
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;
}
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();
}
}
π codepen.io/rx-labz/pen/VwedroV
Etape 2 : Tom Thumb
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)),
),
);
}
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;
}
π codepen.io/rx-labz/pen/NWxzXKG
Etape 3 : The path
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;
}
π codepen.io/rx-labz/pen/BajVJNy
Etape 4 : Ephemeral lines
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)),
),
);
// ..
}
π codepen.io/rx-labz/pen/eYJKyNG
Etape 5 : Moving points
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;
}
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);
}
}
π 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
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,
);
}
}
π codepen.io/rx-labz/pen/rNxKKxP
Points to parallelogram
Now let's draw a parallelogram by connecting edges.
We are going to create a class Segment
, which will contain twoPoint
s. 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,
);
}
So we go from a list of Point
s to a list ofSegment
s.
@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);
}
Then in the Painter, we determine the edges of the parallelogram from the points of the segment and we connect them.
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;
}
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;
}
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,
);
}
}
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);
}
π codepen.io/rx-labz/pen/abdKKNg
Etape 7 : Colors
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,
);
}
}
π 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,
);
}
}
π 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.
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,
);
}
}
π 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);
});
//...
}
// ...
}
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)),
),
),
],
),
);
// ...
}
We now use 2 CustomPainter
s :
-
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);
}
-
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);
}
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,
);
}
π 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);
}
// ...
}
π 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();
}
}
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],
),
);
}
π codepen.io/rx-labz/pen/zYraJRL
Etape 12 : Arrowheads
The simplest way will be to transform the last segment nto a triangle.
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],
),
);
}
π 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),
);
}
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,
);
}
}
π 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;
}
π codepen.io/rx-labz/pen/JjGZaLw
And There you go ! I hope you learned somethings about CustomPaint
.
From there, it's up to you !
Posted on July 9, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.