Du papier au canvas, graphisme génératif avec Dart & Flutter
Petite rétrospective d’une expérimentation graphique de près de 20 ans en 15 étapes.
2002
Un jour, je me suis mis à dessiner des fléches…
beaucoup de flèches 😀 … Du volume, du mouvement,…
2004
Je les ai ensuite colorié avec Photoshop ou Illustrator.
2008
Je développe en ActionScript, des applications Flash puis Flex. C’est à cette époque que je découvre l’art génératif; j’ai la chance de voir Joshua Davis à FITC 2008 à Amsterdam. Je découvre aussi Erik Natzke , ou encore les travaux de Môssieur Nicoptère… Une grosse dose d’inspiration!
Si Flash était un outil tout à fait approprié pour générer ce genre de graphisme… je ne voyais pas encore comment coder mes flèches. Mes expérimentations s’étaient arrêtées à une vague tentative d’interpolation de formes ( les vrais savent !).
2010
Thoughts on Flash… Flash entame sa retraite anticipée.
2015
je continue à développer des applications Flex, mais, forcé et contraint, je commence à m’intéresser à d’autres technologies. Le JS bien sûr :|…, mais Dart aussi 🥰. Durant ces explorations, je suis intrigué puis séduit par le principe des Observables, avec RXJS, mais aussi avec les streams de Dart. Et c’est finallement en appliquant ces concepts à l’utilisation du canvas, que j’ai eu une idée d’implémentation pour générer quelque chose ressemblant à mes flèches 🎉 !
L’idée était finalement simple : un canvas interactif transformant une suite de positions du curseur en polygones “flèchés” : curseur => points => polygones => fleches
Avec ses streams, Dart semblait parfaitement adapté. j’avais en tête quelque chose de ce genre :
window
..onMouseMove.map(mouseToPoint).map(pointToPolygon).listen(onNewPolygon);
2016
J’arrive à boucler une 1ère version de Algraphr en Dart 1 “vanilla”.
Avec le recul, je suis content de m’être lancé dans cette expérimentation. Dart 1 était déjà un language très confortable pour le développement web. J’ai rapidement pu :
- dessiner dynamiquement du SVG,
- transformer le SVG en bitmap,
- l’afficher instantanèment dans un canvas,
- l’exporter dans un PNG
Rien d’impossible à implémenter en JS, mais l’expérience avec Dart fut très plaisante. J’avais été frappé de la simplicité et l’efficacité de Dart pour ce projet : aucune dépendance, aucun outil à configurer 👍.
Un mot sur cette première implémentation : les formes “dynamiques” sont dessinées en SVG, et lorsqu’on les “freeze”, et elles sont redessinées sur un canvas. A dire vrai, je n’imaginais pas réellement au départ qu’une telle implémentation pourrait fonctionner de manière fluide… et pourtant ! ( Cette bonne surprise m’a aidé à poursuivre mon deuil de Flash :) ! )
L’histoire de Algraphr s’est longtemps arrêtée là.
2017
Je découvre Flutter 💙. Tellement de chose à explorer… En quelques semaines je porte une de mes laborieuses applications Flex mobile en Flutter et depuis : 🤩 !
2019
Adobe se sépare de la technologie Adobe AIR, et annonce le retrait définitif de Flash Player.
Apparitions des 1ères expérimentations “génératives” avec Flutter…, puis Flutter web et enfin
avec, je ne sais toujours pas pourquoi, ma tête au milieu 🤯…
Il était plus que temps d’implémenter une version Flutter : Algrafx. Ce fut bien plus simple, et cela m’a permis de rapidement enrichir les options proposées.
2020
Codepen intégre un éditeur Flutter, et on voit fleurir nombre de démonstrations des capacités de Flutter web. Je m’y essaye en intégrant d’abord algrafx dans Codepen… avant de bidouiller une suite de petites animations.
Cf. Codepen/rx-labz
Tout cela nous amène à l’été 2020, et c’est l’occasion de poursuivre mes fléches !
Au programme donc : dessin et animation de flèches dans un canvas Flutter ! Cela nous permettra de voir comment utiliser le Canvas de Flutter, pour opérations simples ( dessin de forme ) et plus évoluées ( dégradés, flou ).
➡ Pour commencer
Nous allons créer une application Flutter contenant un widget CustomPaint
.
CustomPaint
nous donne accès à la couche de painting, et va nous permettre de manipuler un Canvas
, offrant les méthodes habituelles de dessins : moveTo
, lineTo
, drawRect
, drawCircle
…
La responsabilité de manipuler le canvas est déléguée à une instance de CustomPainter
, classe qu’il nous faut étendre pour “peindre” nos propres instructions.
import 'dart:ui';
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(body: Board()),
debugShowCheckedModeBanner: false,
));
}
// on récupère la taille de la fenêtre pour dimensionner le dessin dans le canvas
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 : L’origine
Au commencement, il y eut un point…
Les plus grands voyages débutent par un 1er pas, ici nos lignes débuteront par un 1er point, ou plus exactement un rond, placé au centre de la fenêtre.
Le dessin sur le canvas étant, pour l’instant, fixe, shouldRepaint
renvoie false
.
class Painter extends CustomPainter {
static const radius = 10.0;
static final fill = Paint()..color = Colors.red;
@override
void paint(Canvas canvas, Size size) {
// dessine un cercle de 10 pixels de rayon au milieu de l'écran
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
L’idée est d’obtenir une série de points, et pour cela nous allons suivre le curseur. Nous pouvons utiliser un widget MouseRegion
, qui nous permet de récupérer sa position, et l’injecter ensuite dans le painter.
class Board extends StatefulWidget {
@override
_BoardState createState() => _BoardState();
}
class _BoardState extends State<Board> {
Offset mouse = Offset.zero;
@override
Widget build(BuildContext context) => MouseRegion(
// met à jour la position "mouse" à chaque mouvement de la souris
onHover: (details) => setState(() => mouse = details.localPosition),
// construit un CustomPaint basé sur un Painter utilisant la position enregistrée
child: CustomPaint(size: size, painter: Painter(mouse)),
);
}
Nous injectons la position dans le painter, afin de re-dessiner le cercle à la position du curseur. A présent, le canvas sera re-dessiné pour chaque position du curseur, et shouldRepaint
devra renvoyer true
si la position change.
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) {
// dessine un cercle à la position reçue
canvas.drawCircle(position, radius, fill);
}
@override
bool shouldRepaint(Painter oldDelegate) => position != oldDelegate.position;
}
Stream
L’objectif étant de générer un graphisme en transformant un flux d’entrées en formes géometriques, on peut utiliser un stream
et y ajouter les positions successives du curseur. Le Painter
sera re-dessiné pour chaque valeur émise.
// cf.
class _BoardState extends State<Board> {
// on crée un streamController : un émetteur de positions
final StreamController<Offset> _streamer = StreamController<Offset>();
// le flux de positions
Stream<Offset> get point$ => _streamer.stream;
@override
Widget build(BuildContext context) => MouseRegion(
// ajoute les positions successives au flux
onHover: (details) => _streamer.add(details.localPosition),
// reconstruit le CustomPaint à chaque nouvelle position émise par le flux point$
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 : Petit Poucet
Pour dessiner, nous allons laisser des traces du passage du curseur.
Pour cela, au lieu d’émettre seulement un point, nous pouvons cumuler les positions successives du curseur dans une liste _points
.
class _BoardState extends State<Board> {
// liste des points accumulés
final List<Offset> _points = [];
// émetteur de listes de points
final StreamController<List<Offset>> _streamer =
StreamController<List<Offset>>();
Stream<List<Offset>> get _point$ => _streamer.stream;
@override
Widget build(BuildContext context) => MouseRegion(
// ajoute chaque nouvelle position à la liste,
// et émet le nouveau contenu de la liste
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)),
),
);
}
Ne reste plus qu’à dessiner l’ensemble des points listés.
class Painter extends CustomPainter {
// ...
final List<Offset> points;
const Painter(this.points);
@override
void paint(Canvas canvas, Size size) {
// dessine un cercle pour à chaque position de la liste
for (final point in points) canvas.drawCircle(point, 10, fill);
}
@override
bool shouldRepaint(Painter oldDelegate) => true;
}
👀 codepen.io/rx-labz/pen/NWxzXKG
Etape 3 : le chemin
Une fois que l’on a une série de points, on peut les relier. Pour cela on peut :
- tracer des lignes avec
canvas.drawLine()
- tracer des lignes avec
canvas.drawPoints(PointMode.polygon, points, stroke)
, mais cette méthode ne marche pas encore en version web.
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);
// on relie chaque point au point suivant
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 : Lignes ephemères
Pour ne pas surcharger le canvas, nous allons limiter le nombre de points visibles.
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(
// garde les 29 derniers 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 point
Pour animer le tracé, nous allons appliquer une pseudo-force à ses points.
Pour cela, plutôt que de manipuler des Offset
, on peut créer une entité Point
, sur laquelle nous appliquerons un déplacement proportionel à la force appliquée et subissant une légère accélération.
/// pseudo gravité d'1px vertical
const force = Offset(0, 1);
// facteur d'accéleration de la gravité
const acceleration = 1.1;
class Point {
// position
final Offset offset;
// gravité
final Offset force;
// visible ou hors champs
final bool active;
const Point(this.offset, this.force, [this.active = true]);
// application de la force à la position et de l'accélération à la 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;
}
Pour “rafraîchir” le dessin nous utilisons un controller d’animation “infinie” ( AnimationController.unbounded
). Cela nous permet de mettre à jour la position et la force de chaque point à intervalle régulier.
Pour pouvoir utiliser une animation, on ajoute le mixin SingleTickerProviderStateMixin
à notre _BoardState
.
A chaque tick, on filtre les points visibles, et on leur applique la force de manière à calculer leur nouvelle position.
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() {
// démarre une animation en boucle et ajoute un écouteur `_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)),
),
);
// met à jour les points et les ajoute au flux
void _updatePoints() {
_points = _points
// filtre les points actifs,
.where((element) => element.active)
// met à jour leur position et leur force
.map((element) => element.update())
.toList();
_streamer.add(_points);
}
}
👀 codepen.io/rx-labz/pen/ZEQRREq
Etape 6 : Points => Segment => Polygone
Maintenant que nous avons notre liste de points, nous allons la transformer.
✴ ➡ |
Pour commencer nous allons tracer une ligne verticale au niveau de chaque point.
@override
void paint(Canvas canvas, Size size) {
print('Painter.paint...');
for (final point in points) {
canvas.drawCircle(point.offset, 2, fill);
// dessine un segment vertical au niveau de chaque point
canvas.drawLine(
point.offset - Offset(0, -50),
point.offset - Offset(0, 50),
stroke,
);
}
}
👀 codepen.io/rx-labz/pen/rNxKKxP
✴ ➡ ▱
Nous dessinons à présent un parallèlogramme reliant deux points successifs.
Nous allons pour cela créer une classe Segment
, qui contiendra deux Point
s. Les segments posséderont également une couleur de remplissage et de contour. Les parralèlogrammes seront obtenus par transformation de ce 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;
bool get active => point1.active && point2.active;
const Segment(this.point1, this.point2, {this.strokeColor, this.fillColor});
// renvoie un segment avec les points mis à jour
Segment update() => Segment(
point1.update(),
point2.update(),
strokeColor: strokeColor,
fillColor: fillColor,
);
}
Nous passons donc d’une liste de Point
s à une liste de Segment
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(
// ajoute un segment à chaque nouvelle position du curseur
onHover: (details) => _addSegment(details.position),
child: StreamBuilder<List<Segment>>(
initialData: <Segment>[],
stream: _segment$,
builder: (_, stream) =>
CustomPaint(size: size, painter: Painter(stream.data)),
),
);
/// ajoute un segment entre le dernier point du segment précédent et la nouvelle position
void _addSegment(Offset offset) {
_segments
..add(
Segment(
_segments.isEmpty ? Point(offset, force) : _segments.last.point2,
Point(offset, force),
strokeColor: strokeColor,
fillColor: fillColor,
),
);
}
// filtre le segments inactives et met à jour les segments et les ajoute au flux
void _updateSegments() {
_segments = _segments
.where((element) => element.active)
.map((element) => element.update())
.toList();
_streamer.add(_segments);
}
Ensuite dans le Painter
, nous déterminons les arrêtes du parallélogramme à partir des points du segments et nous les relions.
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;
}
Une meilleure API
Le résultat est bien celui recherché, mais simplifions un peu l’API.
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;
/// renvoie les coins du parallèlogramme correspondant au 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,
);
}
}
Pour finir, redessinons les paralèlogrammes à l’aide de 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) {
// instanciation d'un path entre les quatres corners
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();
// dessin de la forme
canvas.drawPath(path, Paint()..color = segment.fillColor);
// dessin du contour
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 : Couleurs
Pour animer la couleur, nous allons progressivement assombrir les couleurs appliquées à chaque segment. Pour cela nous pouvons convertir la couleur en HSLColor
et baisser la luminosité. L’utilisation d’une extension permet de simplifier l’écriture de cette opération.
extension on Color {
/// renvoie la HSLColor correspondante
HSLColor get hsl => HSLColor.fromColor(this);
/// renvoie la luminosité de la couleur
double get lightness => hsl.lightness;
/// renvoie la couleur après modification de la luminosité
Color withLightness(double value) =>
hsl.withLightness(min(1, hsl.lightness * .98)).toColor();
}
class Segment {
//..
Segment update() {
// assombrit la couleur de remplissage
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/oNbyMYG
See the Pen Algfx_7 by rxlabz (@rx-labz) on CodePen.
Etape 8 : Vitesse et épaisseur
L’étape suivante va consister à faire varier l’épaisseur du bandeau en fonction de la vitesse de déplacement du curseur. Plus le curseur se déplace rapidement plus la ligne est fine.
a. Épaisseur de segment
Pour cela nous allons ajouter une épaisseur width
aux segments, et la faire varier en fonction de la distance entre les 2 points. Le parallèlogramme dessiné sera défini par l’épaisseur.
/// largeur maximale
const segmentMaxWidth = 100.0;
const segmentMinWidth = 2.0;
/// longueur de référence
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),
];
}
/// renvoie l'épaisseur du segment en fonction de la distance entre ses 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. Chainage des segments
Pour “harmoniser” la ligne, nous allons transformer les parallélogrammes en trapèzes. Chaque trapèze aura une épaisseur “en entrée” et une épaisseur en sortie.
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, // segment précédent
this.strokeColor,
this.fillColor,
});
bool get active => point1.active && point2.active;
/// renvoie les coins du trapèze basés sur l'épaisseur du segment précédent
/// et celle du segment lui même
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
Chaque segment contient le segment précedent, et peut donc obtenir l’épaisseur de l’extremité connexe.
Etape 9 : snapshot
Pour le moment tous les polygones disparaissent, nous allons ensuite maintenant les “fixer”.
La fixation pourrait être déclenchée manuellement ( au clic ou via la barre d’espace ), comme c’est le cas avec algrafx, mais dans cet exemple nous allons les figer automatiquement, à intervalle de temps régulier.
class Point {
// ...
/// chaque point peut être figé, en annulant la force qui lui est appliquée
/// et en le désactivant
Point freeze() => Point(offset, Offset.zero, false);
// ...
}
class Segment {
// ...
/// les segments peuvent également être figés
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() {
//...
// fige les segments visible à intervalle régulier
Timer.periodic(Duration(seconds: 2), (timer) {
final freezables = _segments
.where((segment) => segment.active)
.map((segment) => segment.freeze())
.toList();
_freezedLines.add([...freezables]);
_freezedStreamer.add(_freezedLines);
});
//...
}
// ...
}
Pour ne pas redessiner plus que nécessaire les segments figés, nous allons créer un deuxième CustomPaint, sorte de calque, qui sera utilisé pour dessiner les polygones figés, et qui ne sera rafraîchit que lorsque la liste de segments change.
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)),
),
),
],
),
);
// ...
}
Nous utilisons maintenant 2 CustomPainter
s :
ForegroundPainter
dessine les segments “dynamiques”
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
dessine les segments figés
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);
}
les deux painters utilise une méthode 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
Pour finir, nous pouvons remplacer le tracking du mouvement du curseur, et générer des positions aléatoires.
Le nombre de lignes figées est ici limité à 5.
// ...
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();
}
// deplacement aléatoire
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
Voilà pour le principe, mais il manque encore quelques finitions :
- ajouter un peu de volume et de lumière à l’aide de dégradés
- ajouter la pointe des flèches
- jouer avec le flou et l’opacité
- entrelacer les fléches via un pseudo “z-ordering”
Etape 11 : Dégradés
Pour ajouter un dégradé aux trapèzes, déclinons la couleur du segment : une version plus claire, et une plus sombre.
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();
}
}
Ensuite, ajoutons un dégradé entre la couleur claire, la couleur réelle et la couleur sombre.
Pour créer un dégradé dans le canvas, on ajouter un shader
de 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()
// définition d'un dégradé sur une diagonale du polygone,
// entre un intervalle de couleur autoir de la couleur du segment
..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 : Pointes
Il est temps de dessiner la pointe de nos fléches. Le moyen le plus simple sera de transformer le premier segment non pas en parallèlogramme, mais en 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 : Flou et opacité
Pour adoucir les tracés, nous pouvons superposer une version floutée des polygones. Cela produit un effet “lueur“ qui associée à une variation de l’opacité peut produire un effet graphique intéressant.
Pour cela nous allons utiliser un Paint.maskFilter
. Les polygones seront progressivement floutés.
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());
// dessin net
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],
),
);
// dessin flouté
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],
)
// on applique le flou
..maskFilter = MaskFilter.blur(
BlurStyle.normal, (total - count) / total * blurFactor),
);
}
pour l’opacité, nous modifions la couleur de remplissage au moment de la mise à jour des segments.
class Segment{
// ...
Segment update() {
final newFillColor = fillColor
.withLightness(max(0.05, fillColor.lightness * lightnessFactor))
// applique un facteur de transparence
.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
Pour cette dernière étape, l’objectif est d’entremêler les polygones successifs. Pour cela nous allons ré-ordonner les segments figés en fonctions de leur opacité.
class BackgroundPainter extends CustomPainter {
final List<List<Segment>> lines;
BackgroundPainter(this.lines);
@override
void paint(Canvas canvas, Size size) {
final allSegments = <Segment>[];
// liste l'ensemble des segments
for (final segments in lines) {
for (final segment in segments) {
allSegments.add(segment == segments.last ? segment.lastified : segment);
}
}
// trie les segments en fonctions de leur opacité
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
See the Pen Algfx_14 by rxlabz (@rx-labz) on CodePen.
Et voilà ! Nous sommes arrivés à un rendu relativement proche des flèches initiales, et nous avons fait ample connaissances avec CustomPaint
.
A partir de là, à vous de jouer !