Bacterial growth is the classic real-world example of exponential growth. It’s a good visual analogy for this complex topic. Let’s try and implement this in Flutter.
Goal
This is how the final implementation result should look like:
Rules
First, we define the rules we set for the simulation:
- It starts with one bacteria
- There are two events that can happen regularly (every 30 milliseconds): cell division and bacteria dying
- Bacteria can double itself using cell division. The probability for this is 0.1 %
- Bacteria can die. The probability for this is 0.5 %
- There can be no more than 1024 bacteria inside the simulation
Coding
First, let’s define a model for our Bacteria
.
In the real world, a bacteria is already a very complex object.
For our first iteration of the simulation, we only need the position on the screen.
1import 'dart:math';
2import 'dart:ui';
3
4class Bacteria {
5 Bacteria(this.x, this.y);
6
7 double x;
8 double y;
9}
Using these rules, we can start with a widget that has these rules set as constants:
1class PetriDishIterative extends StatefulWidget {
2 const PetriDishIterative({Key? key}) : super(key: key);
3
4 @override
5 State<StatefulWidget> createState() {
6 return _PetriDishIterativeState();
7 }
8}
9
10class _PetriDishIterativeState<PetriDish> extends State {
11 static const int tickTime = 30;
12 static const double recreationProbability = 0.005;
13 static const double deathProbability = 0.001;
14 static const double maxBacteriaAmount = 1024;
15
16 List<Bacteria> bacteriaList = <Bacteria>[];
17
18 @override
19 Widget build(BuildContext context) {
20 // TODO: Build the widget tree here
21 }
22}
We call the widget PetriDish
as this is the place where bacteria are usually scientifically observed 🙂.
Before we take care about bacteria being created and dynamic changes, we start to fill the bacteriaList
with static Bacteria
and display them on screen according to their position.
Statically drawing the bacteria
While the PetriDish
is sort of the widget around everything that’s going on, we want to have a clean separation and create another widget that’s only responsible for painting all the Bacteria
it gets injected.
We call this widget BacteriaCollection
:
1class BacteriaCollection extends StatelessWidget {
2 const BacteriaCollection({required this.bacteriaList});
3
4 final List<Bacteria> bacteriaList;
5
6 @override
7 Widget build(BuildContext context) {
8 final List<Widget> widgetList = bacteriaList
9 .map(
10 (Bacteria bacteria) => _buildWidgetFromBacteria(bacteria),
11 )
12 .toList();
13
14 return Stack(children: widgetList);
15 }
16
17 Positioned _buildWidgetFromBacteria(Bacteria bacteria) {
18 return Positioned(
19 left: bacteria.x,
20 top: bacteria.y,
21 child: Container(
22 width: 10,
23 height: 10,
24 color: Colors.black,
25 ),
26 );
27 }
28}
It’s pretty simple: we use the map
method, which is defined on every Iterable
to iterate over every bacteria inside the list that’s provided in the constructor.
Then we use the x
and y
to position a Container
absolutely on screen with a fixed size and color.
Now we need to use this widget inside our PetriDish
:
1@override
2Widget build(BuildContext context) {
3 return BacteriaCollection(bacteriaList: bacteriaList);
4}
Right now, the build()
method does nothing else than directly rendering our BacteriaCollection
widget.
Okay, we managed to draw squares on a screen depending on a statically defined List
. Let’s advance to the next step: making the List
grow and shrink dynamically.
Randomly spawned bacteria
For randomly spawned bacteria instead of statically defined ones, we are going to need a bunch of methods:
_tick()
– This method defines everything that happens regularly (every 30 ms)_createInitialBacteria()
– Here we add the first bacteria to the list_iterateAllBacteria()
– We iterate over every bacteria in the list and decide if it dies or reproduces_createNewBacteria()
– This is called when a new bacteria is supposed to be added_updateBacteriaList()
– We call this every time the bacteria list changes to update the state
1Timer? timer;
2
3@override
4void initState() {
5 timer = Timer.periodic(const Duration(milliseconds: tickTime), (timer) {
6 _tick();
7 });
8 super.initState();
9}
10
11@override
12void dispose() {
13 timer?.cancel();
14 super.dispose();
15}
16
17void _tick() {
18 if (bacteriaList.isEmpty) {
19 _createInitialBacteria();
20 return;
21 }
22
23 _iterateAllBacteria();
24}
25
26void _createInitialBacteria() {
27 // TODO: Implement
28}
29
30void _iterateAllBacteria() {
31 final List<Bacteria> newList = <Bacteria>[];
32
33 for (final Bacteria bacteria in bacteriaList) {
34 _createNewBacteria(bacteria, newList);
35 }
36
37 _updateBacteriaList(newList);
38}
39
40void _createNewBacteria(Bacteria bacteria, List<Bacteria> newList) {
41 // TODO: Implement
42}
43
44void _updateBacteriaList(List<Bacteria> newList) {
45 setState(() {
46 bacteriaList = newList;
47 });
48}
I have left out the _createInitialBacteria()
and _createNewBacteria()
for now because they requires something else to be done first.
Is it was already mentioned, the _tick()
method is executed regularly. We ensure this by creating a Timer
inside the setState()
method of the widget and use the named constructor Timer.periodic
to make it happen every tickTime
.
To make the timer end when the widget is disposed, we cancel()
the Timer
on dispose()
.
Inside the _tick()
method we decide whether we call _createInitialBacteria()
(when there is no bacteria yet) or _iterateAllBacteria()
otherwise.
There is still one issue: if we want to bacteria to spawn randomly, we need to know the size of the surrounding widget because we want it to spawn inside the boundaries.
1Size size = Size.zero;
2…
3@override
4Widget build(BuildContext context) {
5 return LayoutBuilder(
6 builder: (BuildContext context, BoxConstraints constraints) {
7 size = constraints.biggest;
8 return SizedBox(
9 width: size.width,
10 height: size.height,
11 child: BacteriaCollection(bacteriaList: bacteriaList),
12 );
13 },
14 );
15}
In order to achieve what we want, we initialize a Size
which we set inside a LayoutBuilder
. This way, we ensure, that the boundaries in which we spawn the Bacteria
is always the actual boundary of the surrounding widget.
Now we need a logic to spawn a new Bacteria. This includes two cases:
- Creating an entirely randomly placed bacteria (this will be used for the initial bacteria)
- Creating a new bacteria from an existing one (cell division and movement)
1class Bacteria {
2 Bacteria(this.x, this.y);
3
4 factory Bacteria.createRandomFromBounds(double width, double height) {
5 final double x = Random().nextDouble() * width;
6 final double y = Random().nextDouble() * height;
7
8 return Bacteria(x, y);
9 }
10
11 factory Bacteria.createRandomFromExistingBacteria(
12 Size environmentSize,
13 Bacteria existingBacteria,
14 ) {
15 double newX = existingBacteria.x + existingBacteria._getMovementAddition();
16 double newY = existingBacteria.y + existingBacteria._getMovementAddition();
17
18 if (newX < -existingBacteria.width) {
19 newX = environmentSize.width;
20 } else if (newX > environmentSize.width + existingBacteria.width) {
21 newX = 0;
22 }
23
24 if (newY < -existingBacteria.height) {
25 newY = environmentSize.height;
26 } else if (newY > environmentSize.height + existingBacteria.height) {
27 newY = 0;
28 }
29
30 final double x = newX;
31 final double y = newY;
32
33 return Bacteria(x, y);
34 }
35
36 double _getMovementAddition() {
37 final double movementMax = width / 6;
38 return Random().nextDouble() * movementMax - movementMax / 2;
39 }
40
41 double x;
42 double y;
43 double rotation;
44 final double height = 24;
45 final double width = 12;
46}
The first case is pretty simple. We use a random value for x
and y
within the given boundaries and return the newly created Bacteria
with these coordinates.
The other case is a bit more complicated: because we don’t want the new bacteria to be placed exactly onto the previous one, we add some random movement. If the new position exceeds the boundaries, say if it is on the very left of the screen, it appears on the very right. Same with the vertical direction.
1
2void _createInitialBacteria() {
3 final List<Bacteria> newList = <Bacteria>[];
4 newList.add(Bacteria.createRandomFromBounds(size.width, size.height));
5
6 _updateBacteriaList(newList);
7}
8
9void _createNewBacteria(Bacteria bacteria, List<Bacteria> newList) {
10 final Bacteria movedBacteria = Bacteria.createRandomFromExistingBacteria(
11 size,
12 bacteria,
13 );
14
15 newList.add(movedBacteria);
16
17 final bool shouldCreateNew =
18 Random().nextDouble() > 1 - recreationProbability;
19
20 if (shouldCreateNew && bacteriaList.length < maxBacteriaAmount) {
21 newList.add(
22 Bacteria.createRandomFromExistingBacteria(size, bacteria),
23 );
24 }
25}
Now we can use the Bacteria.createRandomFromBounds()
and the Bacteria.createRandomFromExistingBacteria()
methods to create the Bacteria
dynamically.
Cosmetic adjustments
The reproduction behavior feels a little bit “unnatural” because none of the bacteria ever die. So let’s implement a certain probability for dying bacteria:
1void _iterateAllBacteria() {
2 final List<Bacteria> newList = <Bacteria>[];
3
4 for (final Bacteria bacteria in bacteriaList) {
5 final bool shouldKill = Random().nextDouble() > 1 - deathProbability;
6
7 if (!shouldKill) {
8 final Bacteria movedBacteria =
9 Bacteria.createRandomFromExistingBacteria(
10 size,
11 bacteria,
12 );
13 newList.add(movedBacteria);
14 }
15
16 _createNewBacteria(bacteria, newList);
17 }
18
19 _updateBacteriaList(newList);
20}
21
22void _createNewBacteria(Bacteria bacteria, List<Bacteria> newList) {
23 final bool shouldCreateNew =
24 Random().nextDouble() > 1 - recreationProbability;
25
26 if (shouldCreateNew && bacteriaList.length < maxBacteriaAmount) {
27 newList.add(
28 Bacteria.createRandomFromExistingBacteria(size, bacteria),
29 );
30 }
31}
We only copy the bacteria from the existing into the new list if a random number between 0 and 1 is below our deathProbability
.
Now let’s make the bacteria a little bit more like a bacteria. For this, we change the Positioned
widget inside our BacteriaCollection
widget.
1Positioned _buildWidgetFromBacteria(Bacteria bacteria) {
2 return Positioned(
3 left: bacteria.x,
4 top: bacteria.y,
5 child: Container(
6 decoration: BoxDecoration(
7 borderRadius: BorderRadius.circular(4),
8 color: Colors.black38,
9 ),
10 width: bacteria.width,
11 height: bacteria.height,
12 ),
13 );
14}
We give the bacteria a half-transparent color, BorderRadius
and use the width
and height
from the model.
What still bothers me is that all bacteria are oriented in the same direction. That’s why we give the Bacteria
a rotation
:
1class Bacteria {
2 Bacteria(this.x, this.y, this.rotation);
3
4 factory Bacteria.createRandomFromBounds(double width, double height) {
5 final double x = Random().nextDouble() * width;
6 final double y = Random().nextDouble() * height;
7 final double rotation = Random().nextDouble() * pi;
8
9 return Bacteria(x, y, rotation);
10 }
11
12 factory Bacteria.createRandomFromExistingBacteria(
13 Size environmentSize,
14 Bacteria existingBacteria,
15 ) {
16 …
17 final double rotation = existingBacteria.rotation + (Random().nextDouble() * 2 - 1) * pi / 40;
18
19 return Bacteria(x, y, rotation);
20 }
21
22 …
23 double rotation;
24}
The new rotation os based on the old rotation and within a certain random value so that it still looks natural.
This looks a lot more like actual bacteria.
Improving performance
We are using the widget tree to draw the bacteria. This has a significant impact on the performance even with a bacteria amount lower than 500:
Let’s improve this circumstance by using the canvas instead.
1class BacteriaCollectionPainter extends CustomPainter {
2 const BacteriaCollectionPainter({required this.bacteriaList});
3
4 final List<Bacteria> bacteriaList;
5
6 @override
7 void paint(Canvas canvas, Size size) {
8 final Paint paint = Paint();
9 for (final Bacteria bacteria in bacteriaList) {
10 final Rect rect = Rect.fromLTWH(
11 bacteria.x,
12 size.height - bacteria.y,
13 bacteria.width,
14 bacteria.height,
15 );
16 final RRect roundedRectangle = RRect.fromRectAndRadius(
17 rect,
18 Radius.circular(bacteria.width / 2),
19 );
20 paint.strokeWidth = 2;
21 paint.color = Colors.black38;
22
23 _drawRotated(
24 canvas,
25 Offset(
26 bacteria.x + (bacteria.width / 2),
27 bacteria.y + (bacteria.height / 2),
28 ),
29 bacteria.rotation,
30 () => canvas.drawRRect(roundedRectangle, paint),
31 );
32 }
33 }
34
35 void _drawRotated(
36 Canvas canvas,
37 Offset center,
38 double angle,
39 VoidCallback drawFunction,
40 ) {
41 canvas.save();
42 canvas.translate(center.dx, center.dy);
43 canvas.rotate(angle);
44 canvas.translate(-center.dx, -center.dy);
45 drawFunction();
46 canvas.restore();
47 }
48
49 @override
50 bool shouldRepaint(CustomPainter oldDelegate) {
51 return oldDelegate != this;
52 }
53}
What we want to do is to draw rounded rectangles at the position, with the rotation the size the Bacteria
has.
While we used a Positioned
widget in the widget tree variant, we’re going for a RRect
here.
We need the possibility to draw something being rotated by a certain angle. In order to do that, we add a new method called _drawRotated()
which rotates the canvas before drawing in order to achieve the effect of something being drawn rotated by a given angle.
If you want to know more about rotating objects on canvas, read the respective article about rotation on canvas.
What’s left to do is to embed the CustomPainter
into a CustomPaint
widget which we use in our BacteriaCollection
widget:
1class BacteriaCollection extends StatelessWidget {
2 const BacteriaCollection({required this.bacteriaList});
3
4 final List<Bacteria> bacteriaList;
5
6 @override
7 Widget build(BuildContext context) {
8 return CustomPaint(
9 painter: BacteriaCollectionPainter(bacteriaList: bacteriaList),
10 );
11 }
12}
Now we have the same performance problems only at a bacteria amount of 4000.
Growth history chart
Wouldn’t it be nice to track the growth and have a chart displaying it? It should give us the well-known exponential graph.
We need four new widgets for that:
BacteriaHistoryGraph
– This is the surrounding widget of everything that has to do with our graph. No graph itself is painted here, it’s just some rectangle that encloses the chartBacteriaGrowthHistoryElement
– This is the data model of one chart point. It has thetickNumber
(representing the ticks passed and is thex
value of the chart) andnumberOfBacteria
(which is the absolute number of bacteria at a certainx
and is they
value of the chart)HistoryGraph
– The wrapper around our canvas as aCustomPainter
needs aCustomPaint
widget to be drawnBacteriaGrowthChartPainter
– This class extendsCustomPainter
and does the actual drawing
Let’s look at the BacteriaHistoryGraph
first:
1class BacteriaHistoryGraph extends StatelessWidget {
2 const BacteriaHistoryGraph({
3 required this.historyElements,
4 required this.currentTick,
5 required this.currentBacteriaAmount,
6 });
7
8 static const double opacity = 0.5;
9 static const double padding = 32;
10
11 final List<BacteriaGrowthHistoryElement> historyElements;
12 final int currentTick;
13 final int currentBacteriaAmount;
14
15 @override
16 Widget build(BuildContext context) {
17 return LayoutBuilder(
18 builder: (BuildContext context, BoxConstraints constraints) {
19 return Opacity(
20 opacity: opacity,
21 child: Container(
22 padding: const EdgeInsets.all(
23 padding,
24 ),
25 decoration: BoxDecoration(
26 color: Colors.white,
27 borderRadius: BorderRadius.circular(16),
28 boxShadow: <BoxShadow>[
29 BoxShadow(
30 color: Colors.black.withOpacity(0.2),
31 blurRadius: 12,
32 )
33 ],
34 ),
35 child: _buildMainPart(constraints),
36 ),
37 );
38 },
39 );
40 }
41
42 Widget _buildMainPart(BoxConstraints constraints) {
43 if (historyElements.isEmpty) return Container();
44
45 return Stack(
46 fit: StackFit.expand,
47 children: <Widget>[
48 HistoryGraph(
49 historyElements: historyElements,
50 currentTick: currentTick,
51 currentBacteriaAmount: currentBacteriaAmount,
52 ),
53 _buildInfoText()
54 ],
55 );
56 }
57
58 Positioned _buildInfoText() {
59 return Positioned(
60 bottom: 0,
61 right: 0,
62 child: Container(
63 padding: const EdgeInsets.all(8),
64 color: Colors.white70,
65 child: Text(
66 '${historyElements.last.amountOfBacteria} Bacteria',
67 ),
68 ),
69 );
70 }
71}
In the constructor, we expect the following arguments:
historyElements
– The list of data modelscurrentTick
– The current tick so that we can adjust the chart properly on the x-axiscurrentBacteriaAmount
– The current bacteria amount so that we can adjust the chart properly on the y-axis
We embed the chart itself (HistoryGraph
) visually in a rounded rectangle. We also stack some text above it that shows the current amount of bacteria.
1class BacteriaGrowthHistoryElement {
2 BacteriaGrowthHistoryElement({
3 required this.tickNumber,
4 required this.amountOfBacteria,
5 });
6
7 final int tickNumber;
8 final int amountOfBacteria;
9}
Like it was said above, we need the tickNumber
(x value) and the amountOfBacteria
(y value) to display for every data point.
1class HistoryGraph extends StatelessWidget {
2 const HistoryGraph({
3 required this.historyElements,
4 required this.currentTick,
5 required this.currentBacteriaAmount,
6 });
7
8 final List<BacteriaGrowthHistoryElement> historyElements;
9 final int currentTick;
10 final int currentBacteriaAmount;
11
12 @override
13 Widget build(BuildContext context) {
14 if (historyElements.isEmpty || currentBacteriaAmount == 0) {
15 return Container();
16 }
17 return CustomPaint(
18 painter: BacteriaGrowthChartPainter(
19 historyElements: historyElements,
20 currentTick: currentTick,
21 currentBacteriaAmount: currentBacteriaAmount,
22 ),
23 );
24 }
25}
HistoryGraph
is just a thin wrapper around the graph that uses CustomPaint
.
1class BacteriaGrowthChartPainter extends CustomPainter {
2 const BacteriaGrowthChartPainter({
3 required this.historyElements,
4 required this.currentTick,
5 required this.currentBacteriaAmount,
6 });
7
8 final List<BacteriaGrowthHistoryElement> historyElements;
9 final int currentTick;
10 final int currentBacteriaAmount;
11
12 @override
13 void paint(Canvas canvas, Size size) {
14 final double dotSize = size.height / 60;
15 final Paint paint = Paint();
16
17 for (int i = 0; i < historyElements.length; i++) {
18 final BacteriaGrowthHistoryElement element = historyElements[i];
19 final double x = element.tickNumber / currentTick * size.width;
20 final double y =
21 element.amountOfBacteria / currentBacteriaAmount * size.height;
22
23 if (i == 0) continue;
24
25 final BacteriaGrowthHistoryElement previousElement =
26 historyElements[i - 1];
27 final double previousX =
28 previousElement.tickNumber / currentTick * size.width;
29 final double previousY = previousElement.amountOfBacteria /
30 currentBacteriaAmount *
31 size.height;
32
33 paint.strokeWidth = dotSize;
34
35 canvas.drawLine(
36 Offset(previousX, size.height - previousY),
37 Offset(x, size.height - y),
38 paint,
39 );
40 }
41 }
42
43 @override
44 bool shouldRepaint(CustomPainter oldDelegate) {
45 return oldDelegate != this;
46 }
47}
BacteriaGrowthChartPainter
is where the actual painting happens. Basically we iterate over every data point, drawing a line from the previous to the current data point.
We make the stroke width (dotSize
) depend on the size of the canvas because we don’t want the readability of the graph to depend on the screen size or where this is embedded.
We need to mirror the y value because on canvas, 0|0
is the upper left. We want a cartesian coordinate system instead. That’s why we use size.height - previousY
and size.height - y
.
Conclusion
In this tutorial, we have used Flutter’s capabilities to visualize bacterial growth.
We have learned that the performance depends a lot on whether we use the widget tree or the canvas.
We have tried to keep everything clean by separating the widgets propery.
Cold Stone
agata
Marc
In reply to agata's comment