Generative Art in Flutter
Recently, I gave a talk at FlutterCon Berlin 2023 about creating animations in Flutter with low level APIs, as I was preparing the talk, the content progressed to be a lot about art, more specifically, generative art and its technological history.
I was hugely inspired by Vera Molnar, a great Hungarian artist based in France who is now in her 90’s and still produces work. She is considered one of the pioneers of computer generated generative art.
From a young age, Molnar was fascinated by randomness and was one of the early adapters of using algorithms to render artwork, and in a time when computer generated art was claimed to be dehumanized and its authenticity highly doubted, she argued that working with the randomness in machines and their ability to efficiently produce a high number of outputs was in fact proof of the opposite of those arguments.
Because when you work with a computer, you’re using it as a tool to achieve a closer alignment with your own imagination. You are using your creativity and curiosity and expressing yourself in a medium that gives you more space for exploration than manual labor could ever provide.
Over the years, Molnar’s methods and equipment for producing generative artwork kept evolving. In fact, as early as a decade before she even had access to a computer, she was already creating “generative” art.
By definition, generative art is any practice where the artist uses a system, such as a set of natural language rules, a computer program, or a machine, and then sets it into motion with some degree of autonomy.
In the 50s, she introduced us to the term “Machine Imaginere” or the imaginary machine. She used fundamental principles from mathematics along with analog ‘randomness generators’ such as a roll of dice. In this way, she ‘produced’ sequences of drawings, making modifications to a single parameter at a time. She would then meticulously handcraft each of these variations.
After she gained access to computers, she initially worked with IBM mainframes that didn’t even have monitors. As she transitioned to more advanced systems, she used programming languages like BASIC and Fortran to feed the machine with specific instructions that would control a connected pen plotter. The pen plotter would then use an ink pen and a robot arm to translate the code into drawings.
I was really captivated when I learned how far back drawing with code goes, and how brilliant artists like Molnar and many others were composing art that I couldn’t imagine is possible with the limited technology of their time.
Today, extraordinary works of art are being produced with the sophisticated technology of our time, and Flutter makes for a great tool for such work with its cross platform capabilities and out-of-the-box APIs.
So inspired by Vera Molnar’s artistic style and ideas, we will go over some basic principles of generative art by exploring Flutter’s CustomPainter and the Canvas API and we will introduce animations to make our artwork come to life.
Let’s start coding! 👩🏻💻
Flutter’s CustomPainter & The Canvas API
You can quickly access the Canvas API to start freely painting on the screen by adding a CustomPaint
widget in your widget’s build
method, And providing it with a CustomPainter
implementation.
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: SquareCustomPainter(),
);
}
class SquareCustomPainter extends CustomPainter {
//...
}
The CustomPainter
implementation overrides 2 methods, the paint
method is where the painting is performed with the Canvas
, and the shouldRepaint
method allows you to specify when the paint
method must be called again to update the canvas. We can return false
for now.
class SquareCustomPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
//...
}
@override
bool shouldRepaint(covariant SquareCustomPainter oldDelegate) {
return false;
}
}
Let’s start simple by drawing a rectangle using the canvas’s drawRect
API inside the paint
method
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect();
}
There are multiple ways in which you can draw rectangles. For example, a Rect.fromCenter
allows you to draw a rectangle by providing the offset of its center
and its width
and height
. Here, we’re using the center of the canvas to center the rectangle.
@override
void paint(Canvas canvas, Size size) {
final center = Offset(
size.width / 2,
size.height / 2
);
canvas.drawRect(
Rect.fromCenter(
center: center,
width: size.shortestSide * 0.8,
height: size.shortestSide * 0.8,
),
/* ... */,
);
}
You can then add a Paint
object from which, among others, you can specify the following properties:
style
that takes either aPaintingStyle.stroke
or aPaintingStyle.fill
enum value to specify the style of the pain.- The
color
which applies to either the fill or the stroke depending on thestyle
you provide. strokeWidth
for the stroke style.
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 3;
final center = Offset(
size.width / 2,
size.height / 2
);
canvas.drawRect(
Rect.fromCenter(
center: center,
width: size.shortestSide * 0.8,
height: size.shortestSide * 0.8,
),
paint,
);
}
Result:
The Flutter docs provide helpful images that show you how the different APIs of creating rectangles work:
Let’s use the fromPoints
API as another example:
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 3;
final side = size.shortestSide * 0.8;
canvas.drawRect(
Rect.fromPoints(
Offset.zero,
Offset(side, side),
),
paint,
);
}
You will note that if you try that, you would see an outcome where the rectangle is placed on the top left corner rather than centered. Which brings me to an important concept when working with the Canvas
.
Centering with the Canvas API
When you render a canvas
using a CustomPainter
or any other way (like custom RenderObjects
), it will be positioned at the top left corner of the widget containing it, and any offsetting or translating you do, is done respective to the 0 by 0 point that is the top left corner.
So if you wanted to draw something centered, you need to:
- Start by saving the current status of the canvas with
canvas.save()
- Translate the canvas to it’s center offset.
- Do the drawing.
- And reset the canvas using
canvas.restore()
canvas.save(); // 1
canvas.translate(center.dx, center.dy); // 2
canvas.drawRect( // 3
Rect.fromPoints(
Offset(-side / 2, -side / 2),
Offset(side / 2, side / 2),
),
paint,
);
canvas.restore(); // 4
🔗️ Check out the full commented code for drawing and centering squares with the
CustomPainter
Generative Art Tools
There are a couple of tools involved when it comes to creating generative artwork, and I want to briefly mention some while we implementing those tools using the Canvas API.
Tiling — Generative Art Tool #1
Like tiling a wall, you tile pieces of your artwork by repeating them horizontally and vertically, so we can introduce tiling to our previously created square by creating a grid of it across the canvas. We do that by calculating the number of squares that can fit horizontally and vertically in the widget that contains our CustomPainter
, and draw the squares using a simple for loop. You have access to the Size
of that widget through the CustomPainter
‘s paint
method.
We will also calculate how much to offset the drawing so that the final grid is centered
@override
void paint(Canvas canvas, Size size) {
final xCount = ((size.width + gap) / (side + gap)).floor();
final yCount = ((size.height + gap) / (side + gap)).floor();
final contentSize = Size(
(xCount * side) + ((xCount - 1) * gap),
(yCount * side) + ((yCount - 1) * gap),
);
final offset = Offset(
(size.width - contentSize.width) / 2,
(size.height - contentSize.height) / 2,
);
canvas.save();
canvas.translate(offset.dx, offset.dy);
for (int index = 0; index < totalCount; index++) {
int i = index ~/ yCount;
int j = index % yCount;
canvas.drawRect(
Rect.fromLTWH(
(i * (side + gap)),
(j * (side + gap)),
side,
side,
),
paint,
);
}
canvas.restore();
}
Result:
Recursion — Generative Art Tool #2
Using the programming power of recursion to create artwork elements that can appear repeatedly nested until one or more conditions are met, gives you endless possibilities for manipulating lines and shapes in your canvas. For our grid of squares, we’ll implement recursion in a way that is inspired by one of Vera Molnar’s artworks.
We can do that by using the canvas.drawRect()
method in a recursive function that simply keeps drawing rectangles with a side length that is reduced on each recursive call until a specified minimum length is reached.
// Paint method
for (int index = 0; index < totalCount; index++) {
int i = index ~/ yCount;
int j = index % yCount;
drawNestedSquares( // Recursively draws squares
canvas,
Offset(
(i * (sideLength + gap)),
(j * (sideLength + gap)),
),
sideLength,
paint,
);
}
The recursive function:
void drawNestedSquares(
Canvas canvas,
Offset start,
double side,
Paint paint,
) {
if (sideLength < minSideLength) return;
canvas.drawRect(
Rect.fromLTWH(
start.dx,
start.dy,
side,
side,
),
paint,
);
final nextSideLength = side * 0.8;
final nextStart = Offset(
start.dx + side / 2 - nextSideLength / 2,
start.dy + side / 2 - nextSideLength / 2,
);
drawNestedSquares(canvas, nextStart, nextSideLength, paint);
}
The result so far:
Randomness — Generative Art Tool #3
This wouldn’t be a work inspired by Vera Molnar if it did not include what fascinated her the most, randomness, which is actually a powerful tool that can result in truly mesmerizing outcomes.
We can introduce randomness here in a couple of ways:
- Randomizing the side length of the next square in the recursive function.
- Introducing a randomized depth value that would eventually randomize the size of the smallest square.
void drawNestedSquares(
Canvas canvas,
Offset start,
double side,
Paint paint,
int depth, // ⬅️
) {
if (side < minSideLength || depth <= 0) return;
canvas.drawRect(
Rect.fromLTWH(
start.dx,
start.dy,
side,
side,
),
paint,
);
final nextSideLength = side * (random.nextDouble() * 0.5 + 0.5); // ⬅️
final nextStart = Offset(
start.dx + side / 2 - nextSideLength / 2,
start.dy + side / 2 - nextSideLength / 2,
);
drawNestedSquares(canvas, nextStart, nextSideLength, paint, depth - 1);
}
The outcome:
Widgetbook & Experimenting with input parameters
Similar to what generative artists have been doing since the beginning, you can set up various parameters as system inputs and keep experimenting with non randomized values until you find something that aligns with what you imagined, or even better, you might accidentally stumble on some effects that are event crazier than your imagination!
To be able to quickly and easily do this in Flutter, I used the Widgetbook package and set up a separate main.widgetbook.dart
file so that I can run a separate Flutter app. In this Widgetbook app, in addition to being able to test out different input parameters to the artworks I build, I can create a catalogue of all those artwork widgets, and I can access them easily while building them.
Setting up Widgetbook in your project should be easy by following the docs. What is relevant for this article is the Knobs
feature. And this is what I will be using throughout the article to illustrate some concepts and to experiment with system inputs.
For example, consider our randomized recursive squares, how would it look if the grid gap was 0? What if the squares were thicker, smaller, or larger? We can experiment with all of that and see results instantly by using knobs with our widgets:
WidgetbookUseCase(
name: 'Randomized',
builder: (context) {
return RandomizedRecursiveSquaresGrid(
gap: context.knobs.double.slider( // ⬅️
label: 'Gap',
initialValue: 5,
min: 0,
max: 50,
),
// ...
);
},
)
Here’s a preview of how this would look like:
Okay, black and white is a bit boring, let’s bring some colors into the mix!
Colors
When Vera Molnar first started creating computer generated art, she used a pen plotter machine that didn’t have colors (an older version of the device in the image below). So she would produce the drawing and then color it by hand. She experimented with many material to implement colors, and at some point she even worked with blood!
Thankfully, we have Color
classes in Flutter and we don’t need to do that.
Randomizing colors is tricky!
But it actually gets a bit tricky when you want to randomize colors. The easy and straightforward solution would be to have a list of colors that you randomly pick from. But this is not interesting enough and there is a better way!
An initial alternative that one can think of might be randomizing the R, G, and/or B, values in a color’s RGB format. However, as you see from the following GIF, this produces colors that aren’t so pretty or rich.
Color.fromARGB(a, r, g, b)
The best alternative is to use HSL values (Hue, Saturation, and lightness). You can simply set the saturation & lightness to the fixed values you like, and then randomize the hue value, which allows you to randomize through a spectrum of viable colors whose richness & brightness you can modify using the fixed values.
HSLColor.fromAHSL(a, h /* randomized */, s, l).toColor()
Now if we introduced randomized colors to our previous artwork, we can get the following result:
With the hue value randomized, you can use knobs to adjust the saturation and lightness values to your liking and modify the richness and brightness of the colors of your artwork:
🔗 Here’s the full code of this result
Displacement — Generative Art Tool #4
When it comes to generative art, chaos is a good thing! And we can throw some chaos at our artwork by utilizing another useful tool, displacement.
For example, going back to our original square, I can say that I want to displace each corner from its original place by a randomized distance to create a randomized polygon. This way combining displacement with randomness for yet a deeper level of chaos.
To start, we can use drawPoints
to draw the original rectangle points. (drawRect
can only draw uniform rectangles)
// Paint Method
canvas.drawPoints(
PointMode.polygon,
[
topLeft, // Offset
topRight, // Offset
bottomRight, // Offset
bottomLeft, // Offset
topLeft, // Offset
],
paint,
);
Where topLeft
, topRight
, …etc are pre-calculated offsets based on the square side length and canvas centering offset. Let’s offset those points by a distance that randomly ranges between the negative and positive value of a predefined maxCornersOffset
variable.
// Paint Method
topLeft += Offset(
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
);
topRight += Offset(
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
);
/* ... */
canvas.drawPoints(
PointMode.polygon,
[
topLeft, // Offset
topRight, // Offset
bottomRight, // Offset
bottomLeft, // Offset
topLeft, // Offset
],
paint,
);
Now we can go back to our Widgetbook and experiment with the maxCornerOffset
input and see how the polygon gets more distorted as this value increases and vice versa.
Repetition — Generative Art Tool #5
As a final tool in our generative art tool belt, we can implement some repetition simply by using a for
loop with min
and max
values that we can provide as input parameters to the system.
// Paint Method
final repetition = random.nextInt(10) + minRepetition;
for (int i = 0; i < repetition; i++) {
topLeft += Offset(
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
);
topRight += Offset(
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
maxCornersOffset * 2 * (random.nextDouble() - 0.5),
);
/* ... */
canvas.drawPoints(
PointMode.polygon,
[
topLeft, // Offset
topRight, // Offset
bottomRight, // Offset
bottomLeft, // Offset
topLeft, // Offset
],
paint,
);
}
Using our Widgetbook, we can see the effect of the minRepetition
value, among other inputs, on how our randomized polygons will look like.
Let’s add some finishing touches to make this a viable artwork by:
- Switching to dark mode, because why not!
2. Add tiling (our first generative art tool):
3. And finally, add colors:
And voila! As you can see, using a couple of generative art basics, and a few lines of code that implement basic functionality of Flutter’s Canvas API, we went from a simple boring square, all the way to a canvas filled with shapes, colors, and chaos!
But why stop here? Let’s add in some animations to bring this chaos into life!
Animation
Before we jump into animations, it’s best to invest some time into refactoring. My main goal is reusability and utility getters and methods. So I’m going to create a Polygon
class that will store data for each polygon, like its corner offsets, its color
, its level
in a single repetition of overlayed polygons, and it’s normalized location
in a grid of polygons.
class Polygon {
final Offset topLeft;
final Offset topRight;
final Offset bottomRight;
final Offset bottomLeft;
final Color color;
// Level in a set of overlapping polygons
final double level;
// Normalized location on x and y axis
final Location location;
List<Offset> get points => [
topLeft,
topRight,
bottomRight,
bottomLeft,
topLeft,
];
}
This way I can implement a utility method (e.g. generatePolygonSets
) that will populate that data, and make it possible for me to directly use Polygon
getters in my CustomPainter’s paint
method.
class PolygonsCustomPainter extends CustomPainter {
PolygonsCustomPainter({
required this.random,
this.strokeWidth = 2,
this.maxSideLength = 200,
this.maxCornersOffset = 20,
}) {
polygons = generateDistortedPolygonsSet( // Generates the polygons
random,
maxSideLength: maxSideLength,
maxCornersOffset: maxCornersOffset,
);
}
late final List<Polygon> polygons;
/* ... */
@override
void paint(Canvas canvas, Size size) {
for (int i = 0; i < polygons.length; i++) {
paint.color = polygons[i]
.color
.withOpacity(polygons[i].level);
canvas.drawPoints(
PointMode.polygon,
polygons[i].points, // Polygon getter
paint,
);
}
}
}
This code will loop over the pre-generated polygons and paint them using the Canvas’s drawPoints
API with the points
getter and a color
that varies in opacity based on the level
of the polygon, producing the same output as before.
Additionally, I can store the values of the original corner offsets of the polygon (its square points before the displacement), enabling me to implement a getLerpedPoints
method that utilizes Flutter’s Offset.lerp()
functionality. This basically allows us to get the value between 2 offsets given a normalized value between zero and one.
class Polygon {
// Displaced offsets
final Offset topLeft;
final Offset topRight;
final Offset bottomRight;
final Offset bottomLeft;
// Original offsets
final Offset topLeftOrigin;
final Offset topRightOrigin;
final Offset bottomRightOrigin;
final Offset bottomLeftOrigin;
/* ... */
List<Offset> getLerpedPoints(double value) {
return [
Offset.lerp(topLeft, topLeftOrigin, value)!,
Offset.lerp(topRight, topRightOrigin, value)!,
Offset.lerp(bottomRight, bottomRightOrigin, value)!,
Offset.lerp(bottomLeft, bottomLeftOrigin, value)!,
Offset.lerp(topLeft, topLeftOrigin, value)!,
];
}
}
Now I can use this getLerpedPoints
getter in the paint method with the drawPoints
call, utilize the location
data stored in the Polygon
class, and add a little twist with a sine function, because, math!
// Paint Method
for (int i = 0; i < polygons.length; i++) {
paint.color = polygons[i]
.color
.withOpacity(polygons[i].level);
canvas.drawPoints(
PointMode.polygon,
polygons[i].getLerpedPoints(
// Some cool math ⬇️
0.5 * sin(polygons[i].location.x * 2 * pi) + 0.5
),
paint,
);
}
This will produce the following result:
Okay now let’s REALLY implement animations.
Thought Process 🤔
To decide what I want to animate, I started with a single polygons set and saw that I can animate the opacity of each polygon, making them fade one after the other, and I can also animate each polygon from a uniform square to it’s current shape, also one after the other. This is now easily possible after the refactor!
Animations with the CustomPainter
To set up the animation, we can provide the CustomPainter
with an animationController
and pass it to the repaint
parameter of its super
constructor.
class PolygonsCustomPainter extends CustomPainter {
PolygonsCustomPainter({
required AnimationController animationController, // ⬅️
/* ... */
}) : super(repaint: animationController) { // ⬅️
polygons = generateDistortedPolygonsSet(/* ... */);
}
late final List<Polygon> polygons;
/* ... */
}
This basically triggers a repaint as the animation controller is running. And is equivalent to implementing the shouldRepaint
method of the CustomPainter
like so:
class PolygonsCustomPainter extends CustomPainter {
PolygonsCustomPainter({
required AnimationController animationController,
/* ... */
}) : super(repaint: animationController) {
polygons = generateDistortedPolygonsSet(/* ... */);
}
/* ... */
@override
bool shouldRepaint(covariant PolygonsCustomPainter oldDelegate) {
return animationController.value != oldDelegate.animationController.value;
}
}
Now since what we want to do is not simply animate everything from state A to state B, and we want to have consecutive animations, we need to implement some sort of delay, and to do that we can create multiple Animation
type objects
class PolygonsCustomPainter extends CustomPainter {
PolygonsCustomPainter({
required AnimationController animationController,
/* ... */
}) : super(repaint: animationController) {
polygons = generateDistortedPolygonsSet(/* ... */);
polygonAnimations = generatePolygonAnimations(polygons, animationController);
}
late final List<Animation<double>> polygonAnimations;
late final List<Polygon> polygons;
/* ... */
}
Where each one is a Tween hooked to the parent animation controller with a custom curve and an Interval with begin and end values that we can specify by utilizing our Polygon class and creating getters for the normalized value of when we want each polygon animation to start, and this can use the level of the polygon which is its location within a single repetition of polygons
List<Animation<double>> generatePolygonAnimations(
List<Polygon> polygons,
AnimationController animationController,
) {
return List.generate(
polygons.length,
(i) {
return Tween<double>(begin: 0.2, end: 1).animate(
CurvedAnimation(
parent: animationController,
curve: Interval(
polygons[i].animationStart,
polygons[i].animationEnd,
curve: Curves.easeInOut,
),
),
);
},
);
}
class Polygon {
/* ... */
final double level;
final Location location;
double get animationStart => level; // ⬅️
double get animationEnd =>
(animationStart + 0.2) >= 1.0 ? 1.0 : (animationStart + 0.2); // ⬅️
}
Then we can easily use the animation objects in the loop in the paint method to animate the opacity and the lerped Offset points
// Paint method
for (int i = 0; i < polygons.length; i++) {
final opacity = polygons[i].level * polygonAnimations[i].value * 0.7; // ⬅️
paint.color = polygons[i].color.withOpacity(opacity);
canvas.drawPoints(
PointMode.polygon,
polygons[I].getLerpedPoints(1 - polygonAnimations[I].value), // ⬅️
paint,
);
}
Producing this output:
We can go a step further and utilize our access to the location
data of each polygon and adjust the animationStart
value so that it depends on the x
(or y
) location of the polygon set.
class Polygon {
/* ... */
final double level;
final Location location;
double get animationStart => location.x * level; // ⬅️
double get animationEnd =>
(animationStart + 0.2) >= 1.0 ? 1.0 : (animationStart + 0.2);
}
Which will result in:
Conclusion
This was just an introduction on how you can unlock Flutter tooling and combine it with some basic generative art concepts to start creating beautiful artworks.
And while the generative artists of Vera Molnar’s time were using their coding skills to translate art from the digital to the physical world using pen plotters, I personally believe it will be great to translate this art into the hands of regular mobile app users through captivating and memorable user interfaces. Plus, we can all agree that we deserve more beauty in our lives!