Flutter, Sierpiński e i frattali

La teoria frattale mi ha sempre affascinato, sin da quanto un bel po' di anni fa ho letto "How Long Is the Coast of Britain ?" del matematico Mandelbrot e spesso, in vari linguaggi, mi diverto a realizzare piccole rappresentazioni di semplici insiemi o figure autosimilanti. Per testare anche le animazioni in  Flutter, ho riprodotto il Triangolo di  Sierpiński.


Cosa sono i frattali ?

I frattali sono figure geometriche caratterizzate dal ripetersi sino all'infinito di uno stesso motivo su scala sempre più ridotta. Un frattale è un insieme F che abbia proprietà simili a quelle elencate qui di seguito:

  • Autosimilitudine: F è unione di un numero di parti che, ingrandite di un certo fattore, riproducono tutto F; in altri termini F è unione di copie di se stesso a scale differenti.
  • Struttura fine: F rivela dettagli ad ogni ingrandimento. Non è possibile definire in modo netto ed assoluto i confini dell'insieme (i bordi dell'immagine)

Il Triangolo di Sierpiński

Il triangolo di Sierpiński è un frattale, così chiamato dal nome di Wacław Sierpiński che lo descrisse nel 1915. È un esempio base di insieme auto-similare, cioè matematicamente generato da un pattern che si ripete allo stesso modo su scale diverse, basato su un triangolo equilatero. 


Si può ottenere il triangolo di Sierpiński dalle seguenti successioni infinite:
Partendo dal triangolo equilatero:
  1. Livello 0 Si parte da un triangolo equilatero di lato a.
  2. Livello 1 Si congiungono i punti medi di ciascun lato individuando quattro triangoli simili al primo (di lato a/2) di cui tre ugualmente orientati e uno capovolto.
  3. Livello 2 Si ripete l'operazione di scomposizione precedente su ciascuno dei tre triangoli non capovolti ottenendo 9 triangolini non capovolti di lato a/4.
  4. Livello 3 Si ripete la stessa operazione sui 9 triangoli ottenendone 27 di lato a/8.
  5. Livello 4 Si ripete la stessa operazione sui 27 triangoli ottenendone 81 di lato a/16.
  6. ....
  7. Livello n Si ottengono 3^n triangoli di lato a*2^(-n) (dove a è il lato del triangolo al livello 0).
Continuando all'infinito il limite è il triangolo di Sierpiński.

L'implementazione in Flutter

Partiamo da un nuovo progetto e sostituiamo il codice standard nel file main.dart con questo:


import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Fractal Sierpiński',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {}

Per disegnare la figura frattale abbiamo bisogno di un un widget CustomPaint() e di una variabile che indichi il livello di dettaglio, quindi iniziamo a definire il metodo build in questo modo:


class _MyHomePageState extends State<MyHomePage> {
  int _level = 2;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Center(
        child: Flexible(
          flex: 1,
          child: AspectRatio(
            aspectRatio: 1,
            child: CustomPaint(
              painter: TrianglePainter(_level),
            ),
          ),
        ),
      ),
    );
  }
}

Il painter personalizzato

Per la definizione del painter personalizzato, nella cartella lib creiamo un nuovo file triangle_painter.dart con la definizione iniziale della classe che useremo:


import 'package:flutter/material.dart';
import 'dart:math' as math;

class TrianglePainter extends CustomPainter {
  final int level;

  TrianglePainter(this.level);

  Paint _paint = Paint();

  @override
  void paint(Canvas canvas, Size size) {
    double base = math.min(size.width, size.height);
    double height = base * math.sqrt(3 / 4);
    double xOff = (size.width - base) / 2;
    double yOff = (size.height - height) / 2;

    var triangle = Path();
    triangle.moveTo(xOff + base / 2, yOff + 0);
    triangle.lineTo(xOff + 0, yOff + height);
    triangle.lineTo(xOff + base, yOff + height);
    triangle.close();

    canvas.drawPath(
        triangle,
        _paint
          ..color = Colors.black
          ..style = PaintingStyle.stroke);
  }

  @override
  bool shouldRepaint(TrianglePainter oldDelegate) {
    return (level != oldDelegate.level);
  }
}

Questo codice disegna il triangolo del livello 0, e per farlo

  • determina la dimensione minima del canvas (per poter ottenere la base del nostro triangolo tale che non fuoriesca dal canvas),
  • determina l'altezza del triangolo equilatero associato (applicando il teorema di Pitagora), 
  • determina l'offset verticale ed orizzontale necessario a centrare il nostro triangolo nel canvas,
  • definisce la Path() del triangolo,  
  • infine disegna il triangolo sul canvas.
Al momento ignoriamo il valore del livello, ma possiamo già prevedere che il canvas dovrà essere ridisegnato sicuramente ogni volta che cambia (vedi metodo shouldRepaint() ).    

Il risultato è questo:


Al livello 1, dobbiamo disegnare il triangolo capovolto unendo i punti medi dei lati. Aggiungiamo quindi il codice necessario, definendo una funzione che potremo richiamare all'occorrenza:


  void _drawTriangle(
      Canvas canvas, double x1, double y1, int level, double base) {
    if (level >= 0) {
      double height = base * math.sqrt(3 / 4);
      var triangle = Path();
      triangle.moveTo(x1, y1);
      triangle.lineTo(x1 - base / 2, y1 - height);
      triangle.lineTo(x1 + base / 2, y1 - height);
      triangle.close();

      canvas.drawPath(
          triangle,
          _paint
            ..color = Colors.black
            ..style = PaintingStyle.stroke);
    }
  }

Questo funzione, 

  • verifica che il livello sia maggiore o uguale a zero, altrimenti non fa nulla,
  • determina l'altezza del nuovo triangolo equilatero a partire dalla base
  • definisce la Path() del triangolo,  
  • disegna il triangolo sul canvas.

Non ci resta che richiamarla all'interno del nostro metodo paint() in questo modo: 


    _drawTriangle(canvas, xOff + base / 2, yOff + height, level - 1, base / 2);

(decrementiamo il livello, dimezziamo la base e passiamo le coordinate del vertice inferiore del triangolo da disegnare): 

E' chiaro a questo punto che applicando la ricorsività, potremo ripetere questo procedimento per ogni livello. La nostra funzione _drawTriangle() dovrà ricorsivamente richiamare se stessa (decrementando il livello e dimezzando la base ad ogni step), per il triangolo di sinistra, quello di destra e quello superiore. Aggiungiamo quindi queste righe:


      // left
      _drawTriangle(canvas, x1 - base / 2, y1, level - 1, base / 2);
      // right
      _drawTriangle(canvas, x1 + base / 2, y1, level - 1, base / 2);
      // top
      _drawTriangle(canvas, x1, y1 - height, level - 1, base / 2);

Per un livello 2 e 5 otteniamo rispettivamente questi due risultati


Miglioriamo il codice

Miglioriamo l'interfaccia della nostra applicazione aggiungendo uno slider che permetta di scegliere facilmente il livello desiderato. Lavoriamo sul file main.dart e modifichiamo quanto segue:


class _MyHomePageState extends State<MyHomePage> {
  int _level = 2;
  late int maxLevel = 10;
  late int minLevel = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            children: [
              Row(
                mainAxisSize: MainAxisSize.max,
                children: [
                  Text(
                    "Livello: ",
                    style: TextStyle(fontWeight: FontWeight.bold),
                  ),
                  Expanded(
                    child: Slider(
                      min: minLevel.toDouble(),
                      max: maxLevel.toDouble(),
                      divisions: (maxLevel - minLevel),
                      label: '${_level.round()}',
                      value: _level.toDouble(),
                      onChanged: (value) {
                        setState(() {
                          _level = value.toInt();
                        });
                      },
                    ),
                  ),
                ],
              ),
              Flexible(
                flex: 1,
                child: AspectRatio(
                  aspectRatio: 1,
                  child: CustomPaint(
                    painter: TrianglePainter(_level),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

 con questo risultato.

Aggiungiamo l'animazione

Per come è scritto il codice finora, il disegno del triangolo avviene in un solo momento. Aggiungiamo quindi un effetto di animazione per testare anche questa funzionalità di Flutter.

Aggiungiamo il mixin:


class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
...

un controller, un listenable e la variabile di appoggio


  int _level = 0;
  late int maxLevel = 10;
  late int minLevel = 0;

  late Animation<double> progress;
  late AnimationController controller;
  late int level = 0;

l'interpolazione (al momento inizio e fine coincidono con il valore iniziale del livello):


  Tween<double> tLevel = Tween<double>(begin: 0, end: 0);

inizializziamo il controller e l'animazione, fissando il periodo in 1500 millisecondi e una curva Curves.easeOut :


  @override
  void initState() {
    super.initState();

    controller = AnimationController(
        duration: const Duration(milliseconds: 1500), vsync: this);
    final Animation curveAnimation =
        CurvedAnimation(parent: controller, curve: Curves.easeOut);

    progress = tLevel.animate(curveAnimation as Animation<double>)
      ..addListener(() => _onProgressChanged(progress.value))
      ..addStatusListener((status) async {
        if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });

    controller.forward();
  }

una funzione che aggiorna il livello ad ogni tween:


  void _onProgressChanged(double value) {
    setState(() {
      _level = value.toInt();
    });
  }

e modifichiamo lo slider in modo che intervenga sul valore massimo dell'interpolazione, resettando il controller ogni volta che si modifica il valore del livello desiderato :


                    child: Slider(
                      min: minLevel.toDouble(),
                      max: maxLevel.toDouble(),
                      divisions: (maxLevel - minLevel),
                      label: '${level.round()}',
                      value: level.toDouble(),
                      onChanged: (value) {
                        setState(() {
                          level = value.toInt();
                          tLevel.end = value;
                          controller.reset();
                        });

                      },

Possiamo differenziare i livelli anche per colore. Nel triangle_painter.dart, aggiungiamo una lista di colori:


  final _colors = [
    Colors.lightGreen,
    Colors.lightGreenAccent,
    Colors.blue,
    Colors.blueAccent,
    Colors.orange,
    Colors.orangeAccent,
    Colors.pink,
    Colors.pinkAccent,
  ];

 e modifichiamo le funzioni drawPath() per cambiare colore ad ogni livello:


..color = _colors[(level % _colors.length)]

Il risultato finale che si ottiene è:


Conclusioni

Questo codice è chiaramente solo un punto di partenza per studiare i grafici frattali e le animazioni. Si può cambiare il painter per esempio per disegnare l'insieme di Jiulia altre forme note o si può modificare la curva dell'animazione per avere effetti diversi.

Come per gli articoli precedenti potete trovare il codice tra le mie repo GitHub: 
https://github.com/luigimicco/flutter_sierpinski
 
Nella repo troverete anche uno slider per implementare lo zoom sul canvas.

Commenti