Perché serve CustomPaint

Flutter offre un ricco catalogo di widget pronti all'uso, ma prima o poi ci si imbatte in un requisito che nessun widget standard risolve: un grafico personalizzato, una firma digitale, un indicatore circolare animato o forme geometriche complesse. In questi casi la risposta è CustomPaint, che ci dà accesso diretto al Canvas, lo stesso strato di disegno su cui poggia l'intero framework.

I tre attori principali

Il disegno custom ruota attorno a tre elementi:

  • CustomPaint: il widget che ospita il disegno e lo inserisce nell'albero dei widget.
  • CustomPainter: la classe dove definiamo cosa disegnare, tramite il metodo paint.
  • Canvas: l'oggetto che espone i metodi di disegno (linee, rettangoli, percorsi, testo).

Il primo CustomPainter

Per disegnare bisogna estendere CustomPainter e implementare due metodi: paint e shouldRepaint.

class CerchioPainter extends CustomPainter {
  final Color colore;

  CerchioPainter({required this.colore});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = colore
      ..style = PaintingStyle.fill;

    final centro = Offset(size.width / 2, size.height / 2);
    final raggio = size.width / 3;

    canvas.drawCircle(centro, raggio, paint);
  }

  @override
  bool shouldRepaint(covariant CerchioPainter oldDelegate) {
    return oldDelegate.colore != colore;
  }
}

Il parametro Size rappresenta lo spazio disponibile assegnato al CustomPaint. Usarlo per calcolare le coordinate rende il disegno responsivo.

Inserire il painter nell'interfaccia

CustomPaint(
  size: const Size(200, 200),
  painter: CerchioPainter(colore: Colors.indigo),
)

Se CustomPaint ha un figlio, il disegno funge da sfondo; per disegnare sopra il figlio si usa il parametro foregroundPainter.

L'oggetto Paint

Paint configura l'aspetto di ciò che disegniamo. Le proprietà più usate sono:

  • color: il colore.
  • style: PaintingStyle.fill (riempimento) o PaintingStyle.stroke (contorno).
  • strokeWidth: spessore della linea.
  • strokeCap: stile delle estremità delle linee.
  • shader: per applicare gradienti.
  • isAntiAlias: smussatura dei bordi (attiva di default).
final paint = Paint()
  ..style = PaintingStyle.stroke
  ..strokeWidth = 4
  ..strokeCap = StrokeCap.round
  ..shader = const LinearGradient(
    colors: [Colors.blue, Colors.purple],
  ).createShader(Rect.fromLTWH(0, 0, 200, 200));

Disegnare percorsi con Path

Per forme libere si usa Path, che permette di concatenare segmenti, curve e archi. Ecco un esempio che disegna un triangolo:

@override
void paint(Canvas canvas, Size size) {
  final paint = Paint()..color = Colors.teal;

  final path = Path()
    ..moveTo(size.width / 2, 0)
    ..lineTo(size.width, size.height)
    ..lineTo(0, size.height)
    ..close();

  canvas.drawPath(path, paint);
}

Le curve di Bézier (quadraticBezierTo e cubicTo) consentono di creare forme morbide, utili per onde, fumetti o bordi arrotondati personalizzati.

Un esempio pratico: grafico a barre

Mettiamo insieme i concetti per disegnare un semplice grafico a barre.

class BarChartPainter extends CustomPainter {
  final List<double> valori;

  BarChartPainter(this.valori);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.amber;
    final massimo = valori.reduce((a, b) => a > b ? a : b);
    final larghezzaBarra = size.width / (valori.length * 2);

    for (var i = 0; i < valori.length; i++) {
      final altezza = (valori[i] / massimo) * size.height;
      final x = (i * 2 + 0.5) * larghezzaBarra;
      final rect = Rect.fromLTWH(
        x,
        size.height - altezza,
        larghezzaBarra,
        altezza,
      );
      canvas.drawRRect(
        RRect.fromRectAndRadius(rect, const Radius.circular(4)),
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant BarChartPainter oldDelegate) {
    return oldDelegate.valori != valori;
  }
}

Disegnare testo sul canvas

Il Canvas non disegna testo direttamente: serve TextPainter.

final textPainter = TextPainter(
  text: const TextSpan(
    text: '42%',
    style: TextStyle(color: Colors.black, fontSize: 18),
  ),
  textDirection: TextDirection.ltr,
)..layout();

textPainter.paint(canvas, const Offset(10, 10));

Animare un CustomPainter

Combinando CustomPainter con un AnimationController si ottengono animazioni fluide. Basta passare il valore animato al painter e ridisegnare a ogni frame.

AnimatedBuilder(
  animation: controller,
  builder: (context, _) {
    return CustomPaint(
      size: const Size(200, 200),
      painter: ProgressPainter(progresso: controller.value),
    );
  },
)

In questo modo shouldRepaint riceve a ogni tick il nuovo valore e Flutter ridisegna solo l'area interessata.

Considerazioni sulle performance

Il disegno custom è efficiente, ma alcune accortezze fanno la differenza:

  • Implementa correttamente shouldRepaint: ritorna true solo quando qualcosa cambia davvero.
  • Evita di creare oggetti Paint o Path complessi a ogni chiamata di paint se possono essere precalcolati.
  • Per disegni statici e costosi valuta RepaintBoundary per isolare l'area dai rebuild circostanti.
  • Usa canvas.save() e canvas.restore() quando applichi trasformazioni (rotazioni, traslazioni) per non propagarle al resto del disegno.

Conclusione

CustomPaint apre le porte a interfacce che vanno oltre i widget standard: grafici, indicatori, illustrazioni e animazioni vettoriali. Padroneggiare l'API Canvas e l'oggetto Path ti permette di realizzare qualsiasi elemento visivo immaginabile, mantenendo le ottime performance del motore di rendering di Flutter.