Disegni personalizzati in Flutter con CustomPaint e CustomPainter
GuideIntermedio35 min Flutter 3.x

Disegni personalizzati in Flutter con CustomPaint e CustomPainter

Introduzione

Quando i widget standard di Flutter non bastano, entra in gioco il potente sistema di disegno a basso livello basato su CustomPaint e CustomPainter. Grazie a questi strumenti puoi disegnare direttamente sul canvas: linee, curve, cerchi, poligoni, gradienti e testo, con un controllo pixel-perfect.

In questo tutorial costruiremo passo dopo passo alcuni disegni personalizzati, culminando in un indicatore di progresso circolare animato completamente realizzato con CustomPainter. Al termine avrai le basi per creare qualsiasi grafica vettoriale nella tua app.

  1. 1

    Impostare il widget CustomPaint

    Il widget CustomPaint fa da ponte tra l'albero dei widget e il canvas. Ha due proprietà chiave:

    • painter: il CustomPainter che disegna dietro i figli.
    • size: la dimensione dell'area di disegno (se non ci sono figli).

    Partiamo da uno scaffold minimo che riserva uno spazio quadrato al nostro disegno.

    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Scaffold(
            appBar: AppBar(title: const Text('CustomPaint Demo')),
            body: Center(
              child: CustomPaint(
                size: const Size(250, 250),
                painter: MyPainter(),
              ),
            ),
          ),
        );
      }
    }

    Risultato atteso

    L'app si avvia con un'area vuota di 250x250 pixel al centro dello schermo (il painter verrà creato al passo successivo).

  2. 2

    Creare il primo CustomPainter

    Un CustomPainter richiede l'override di due metodi:

    • paint(Canvas canvas, Size size): dove disegni usando l'oggetto Canvas.
    • shouldRepaint(covariant CustomPainter old): restituisce true se il disegno deve essere aggiornato.

    L'oggetto Paint definisce colore, stile (fill/stroke), spessore e altro. Disegniamo un cerchio e una linea di base.

    class MyPainter extends CustomPainter {
      @override
      void paint(Canvas canvas, Size size) {
        final center = Offset(size.width / 2, size.height / 2);
    
        final circlePaint = Paint()
          ..color = Colors.blue.shade200
          ..style = PaintingStyle.fill;
        canvas.drawCircle(center, size.width / 3, circlePaint);
    
        final linePaint = Paint()
          ..color = Colors.blueAccent
          ..strokeWidth = 4
          ..style = PaintingStyle.stroke;
        canvas.drawLine(
          Offset(0, size.height / 2),
          Offset(size.width, size.height / 2),
          linePaint,
        );
      }
    
      @override
      bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
    }

    Risultato atteso

    Vedi un cerchio azzurro pieno con una linea orizzontale che lo attraversa al centro dell'area di disegno.

  3. 3

    Disegnare forme complesse con Path

    La classe Path permette di comporre forme arbitrarie combinando linee (lineTo), curve di Bézier (quadraticBezierTo, cubicTo) e archi. È lo strumento ideale per icone, grafici e forme organiche.

    Disegniamo un triangolo e un'onda usando un Path.

    @override
    void paint(Canvas canvas, Size size) {
      final trianglePaint = Paint()
        ..color = Colors.deepOrange
        ..style = PaintingStyle.fill;
    
      final triangle = Path()
        ..moveTo(size.width / 2, 20)
        ..lineTo(20, size.height - 20)
        ..lineTo(size.width - 20, size.height - 20)
        ..close();
      canvas.drawPath(triangle, trianglePaint);
    
      final wavePaint = Paint()
        ..color = Colors.white
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3;
    
      final wave = Path()..moveTo(0, size.height / 2);
      wave.quadraticBezierTo(
        size.width * 0.25, size.height * 0.3,
        size.width * 0.5, size.height / 2,
      );
      wave.quadraticBezierTo(
        size.width * 0.75, size.height * 0.7,
        size.width, size.height / 2,
      );
      canvas.drawPath(wave, wavePaint);
    }

    Risultato atteso

    Compare un triangolo arancione con sopra un'onda bianca disegnata tramite curve di Bézier.

  4. 4

    Aggiungere gradienti con Shader

    Per rendere il disegno più accattivante possiamo applicare un gradiente al Paint tramite la proprietà shader. Usiamo LinearGradient (o RadialGradient, SweepGradient) e generiamo lo shader passando un Rect di riferimento.

    @override
    void paint(Canvas canvas, Size size) {
      final rect = Offset.zero & size;
    
      final gradientPaint = Paint()
        ..shader = const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [Colors.purple, Colors.orange],
        ).createShader(rect);
    
      final rrect = RRect.fromRectAndRadius(
        Rect.fromLTWH(20, 20, size.width - 40, size.height - 40),
        const Radius.circular(24),
      );
      canvas.drawRRect(rrect, gradientPaint);
    }

    Risultato atteso

    L'area mostra un rettangolo con angoli arrotondati riempito da un gradiente che sfuma dal viola all'arancione.

  5. 5

    Costruire un progress circolare

    Ora mettiamo insieme le tecniche per un componente utile: un indicatore di progresso circolare. Disegniamo un arco di sfondo grigio e un arco colorato che rappresenta la percentuale, usando drawArc.

    Il parametro progress va da 0.0 a 1.0; lo trasformiamo in un angolo in radianti (2 * pi * progress). Il StrokeCap.round arrotonda le estremità.

    import 'dart:math' as math;
    
    class CircularProgressPainter extends CustomPainter {
      final double progress; // 0.0 - 1.0
      CircularProgressPainter(this.progress);
    
      @override
      void paint(Canvas canvas, Size size) {
        final center = Offset(size.width / 2, size.height / 2);
        final radius = size.width / 2 - 10;
        final rect = Rect.fromCircle(center: center, radius: radius);
    
        final bgPaint = Paint()
          ..color = Colors.grey.shade300
          ..style = PaintingStyle.stroke
          ..strokeWidth = 12;
        canvas.drawCircle(center, radius, bgPaint);
    
        final progressPaint = Paint()
          ..color = Colors.teal
          ..style = PaintingStyle.stroke
          ..strokeWidth = 12
          ..strokeCap = StrokeCap.round;
    
        canvas.drawArc(
          rect,
          -math.pi / 2, // parte dall'alto
          2 * math.pi * progress,
          false,
          progressPaint,
        );
      }
    
      @override
      bool shouldRepaint(covariant CircularProgressPainter old) =>
          old.progress != progress;
    }

    Risultato atteso

    Con un valore di progress (es. 0.6) vedi un anello grigio con un arco teal che ne copre il 60% partendo dall'alto.

  6. 6

    Animare il progress con AnimationController

    Per far crescere l'arco in modo fluido combiniamo il painter con un AnimationController. Notare come shouldRepaint che confronta il valore di progress sia fondamentale per far ridisegnare il canvas ad ogni frame senza sprechi.

    class AnimatedProgress extends StatefulWidget {
      const AnimatedProgress({super.key});
    
      @override
      State<AnimatedProgress> createState() => _AnimatedProgressState();
    }
    
    class _AnimatedProgressState extends State<AnimatedProgress>
        with SingleTickerProviderStateMixin {
      late final AnimationController _controller;
    
      @override
      void initState() {
        super.initState();
        _controller = AnimationController(
          vsync: this,
          duration: const Duration(seconds: 2),
        )..forward();
      }
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return AnimatedBuilder(
          animation: _controller,
          builder: (context, _) {
            return CustomPaint(
              size: const Size(200, 200),
              painter: CircularProgressPainter(_controller.value),
              child: SizedBox(
                width: 200,
                height: 200,
                child: Center(
                  child: Text(
                    '${(_controller.value * 100).round()}%',
                    style: const TextStyle(fontSize: 28),
                  ),
                ),
              ),
            );
          },
        );
      }
    }

    Risultato atteso

    L'anello si riempie progressivamente da 0% a 100% in 2 secondi, con la percentuale aggiornata al centro.

  7. 7

    Best practice e ottimizzazione

    Alcuni accorgimenti per usare CustomPainter in modo efficiente:

    • shouldRepaint: restituisci true solo quando i dati cambiano davvero, per evitare ridisegni inutili.
    • RepaintBoundary: avvolgi il CustomPaint per isolare i repaint dal resto dell'albero.
    • Riusa gli oggetti Paint quando possibile invece di crearli ad ogni paint.
    • clipRect/clipPath: limita l'area di disegno per performance e per creare effetti di ritaglio.
    • Per grafica statica e riutilizzabile valuta l'export in Picture con PictureRecorder.

    Con queste basi puoi disegnare qualsiasi cosa: badge, grafici custom, firme, mappe di calore e molto altro.

    // Isolare i repaint del disegno dal resto della UI
    RepaintBoundary(
      child: CustomPaint(
        size: const Size(200, 200),
        painter: CircularProgressPainter(0.75),
      ),
    )

    Risultato atteso

    Il disegno viene ridipinto solo quando necessario, migliorando le performance dell'app anche con animazioni complesse.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!