Simulating bacterial growth

Simulating bacterial growth

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:

Bacterial growth simulation animated
The bacterial growth simulation we want to implement

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.

Flutter bacterial growth: Statically positioned squares
Statically positioned squares

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.

Flutter bacterial growth: Dynamically positioned squares that move and reproduce
Dynamically positioned squares that move and reproduce

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.

Flutter bacterial growth: Rounded half-transparent bacteria
The shape is a little bit more bacteria-like

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.

Flutter bacterial growth: Rotated bacteria
Rotation makes a lot of difference

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:

Flutter bacterial growth: Performance graph
Performance suffers from constant redrawing

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 chart
  • BacteriaGrowthHistoryElement – This is the data model of one chart point. It has the tickNumber (representing the ticks passed and is the x value of the chart) and numberOfBacteria (which is the absolute number of bacteria at a certain x and is the y value of the chart)
  • HistoryGraph – The wrapper around our canvas as a CustomPainter needs a CustomPaint widget to be drawn
  • BacteriaGrowthChartPainter – This class extends CustomPainter 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 models
  • currentTick – The current tick so that we can adjust the chart properly on the x-axis
  • currentBacteriaAmount – 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.

Flutter bacterial growth: The result
The result of our app

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.

Comments (1) ✍️

Cold Stone

Very Cool!!!
Reply to Cold Stone

Comment this 🤌

You are replying to 's commentRemove reference