CustomPainter en Flutter: ejercicio resuelto

CustomPainter en Flutter: ejercicio resuelto

Cuando un widget estándar no es suficiente, CustomPainter te da acceso directo al Canvas para dibujar formas, trayectorias y texto con control total del píxel. Se usa en gráficos, indicadores animados, firmas digitales y efectos visuales personalizados.

Enunciado

Implementa dos painters:

  1. RingProgressPainter: un anillo de progreso circular (como los de salud o carga).
  2. BarChartPainter: un gráfico de barras simple con etiquetas de valor.

Conecta ambos a un Slider para que el valor cambie en tiempo real.

Dependencias

Solo Flutter SDK — sin paquetes externos.

Solución: RingProgressPainter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// lib/widgets/ring_progress_painter.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';

class RingProgressPainter extends CustomPainter {
  final double progress; // 0.0 – 1.0
  final Color trackColor;
  final Color progressColor;
  final double strokeWidth;

  const RingProgressPainter({
    required this.progress,
    this.trackColor = const Color(0xFFE0E0E0),
    this.progressColor = const Color(0xFF4CAF50),
    this.strokeWidth = 16,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = (math.min(size.width, size.height) - strokeWidth) / 2;

    final trackPaint = Paint()
      ..color = trackColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    final progressPaint = Paint()
      ..color = progressColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    // Pista completa
    canvas.drawCircle(center, radius, trackPaint);

    // Arco de progreso (empieza a las 12h: -π/2)
    final sweepAngle = 2 * math.pi * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      sweepAngle,
      false,
      progressPaint,
    );

    // Texto central con el porcentaje
    final textPainter = TextPainter(
      text: TextSpan(
        text: '${(progress * 100).toInt()}%',
        style: TextStyle(
          color: progressColor,
          fontSize: size.width * 0.2,
          fontWeight: FontWeight.bold,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();

    textPainter.paint(
      canvas,
      center - Offset(textPainter.width / 2, textPainter.height / 2),
    );
  }

  @override
  bool shouldRepaint(RingProgressPainter oldDelegate) =>
      oldDelegate.progress != progress ||
      oldDelegate.progressColor != progressColor;
}

Solución: BarChartPainter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// lib/widgets/bar_chart_painter.dart
import 'package:flutter/material.dart';

class BarChartPainter extends CustomPainter {
  final List<double> values; // normalizados 0.0–1.0
  final List<String> labels;
  final Color barColor;

  const BarChartPainter({
    required this.values,
    required this.labels,
    this.barColor = const Color(0xFF2196F3),
  });

  @override
  void paint(Canvas canvas, Size size) {
    assert(values.length == labels.length);

    const labelHeight = 20.0;
    const barSpacing = 8.0;
    final chartHeight = size.height - labelHeight;
    final barWidth = (size.width - barSpacing * (values.length + 1)) / values.length;

    final barPaint = Paint()
      ..color = barColor
      ..style = PaintingStyle.fill;

    final textStyle = TextStyle(
      color: Colors.grey.shade700,
      fontSize: 11,
    );

    for (var i = 0; i < values.length; i++) {
      final x = barSpacing + i * (barWidth + barSpacing);
      final barHeight = chartHeight * values[i];
      final top = chartHeight - barHeight;

      // Barra con esquinas redondeadas arriba
      final rrect = RRect.fromRectAndCorners(
        Rect.fromLTWH(x, top, barWidth, barHeight),
        topLeft: const Radius.circular(4),
        topRight: const Radius.circular(4),
      );
      canvas.drawRRect(rrect, barPaint);

      // Etiqueta debajo de la barra
      final labelPainter = TextPainter(
        text: TextSpan(text: labels[i], style: textStyle),
        textDirection: TextDirection.ltr,
      )..layout(maxWidth: barWidth);

      labelPainter.paint(
        canvas,
        Offset(x + (barWidth - labelPainter.width) / 2, chartHeight + 4),
      );
    }
  }

  @override
  bool shouldRepaint(BarChartPainter oldDelegate) =>
      oldDelegate.values != values;
}

Solución: pantalla completa

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// lib/main.dart
import 'package:flutter/material.dart';
// import 'widgets/ring_progress_painter.dart';
// import 'widgets/bar_chart_painter.dart';

void main() => runApp(const MaterialApp(home: PainterDemoPage()));

class PainterDemoPage extends StatefulWidget {
  const PainterDemoPage({super.key});

  @override
  State<PainterDemoPage> createState() => _PainterDemoPageState();
}

class _PainterDemoPageState extends State<PainterDemoPage> {
  double _progress = 0.65;

  static const _barValues = [0.4, 0.75, 0.55, 0.9, 0.3, 0.6];
  static const _barLabels = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('CustomPainter Demo')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            // Anillo de progreso
            CustomPaint(
              size: const Size(200, 200),
              painter: RingProgressPainter(progress: _progress),
            ),
            const SizedBox(height: 16),
            Slider(
              value: _progress,
              onChanged: (v) => setState(() => _progress = v),
            ),
            const SizedBox(height: 32),
            // Gráfico de barras
            SizedBox(
              height: 160,
              child: CustomPaint(
                size: Size.infinite,
                painter: BarChartPainter(
                  values: _barValues,
                  labels: _barLabels,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Resultado esperado

  • El anillo muestra el porcentaje en el centro y el arco verde crece al mover el slider.
  • El gráfico de barras muestra 6 columnas con etiquetas de día.
  • Ambos se repintan fluidamente gracias a shouldRepaint.

Cómo funciona shouldRepaint

1
2
3
@override
bool shouldRepaint(RingProgressPainter oldDelegate) =>
    oldDelegate.progress != progress;

Flutter llama a paint solo si shouldRepaint devuelve true. Comparar los campos que cambian evita repaints innecesarios y mantiene el rendimiento a 60/120 fps.

Errores frecuentes

  • No implementar shouldRepaint: siempre retornar true provoca repaint en cada frame aunque nada haya cambiado.
  • Crear Paint() dentro de paint(): los objetos Paint son ligeros pero crea instancias en cada repaint. Muévelos a campos final si el painter no es stateful.
  • Coordenadas relativas al centro: canvas.drawArc usa un Rect, no el centro. Calcula siempre Offset(size.width/2, size.height/2).

Cuándo usar CustomPainter

SituaciónMejor opción
Gráfico a medidaCustomPainter
Animación complejaCustomPainter + AnimationController
Gráfico de producción completofl_chart / syncfusion_flutter_charts
Formas decorativas simplesContainer + BoxDecoration

Aplicación práctica

CustomPainter se usa en indicadores de progreso de onboarding, gráficos de analítica, firmas en pantalla, mapas de calor y cualquier efecto que los widgets estándar no pueden conseguir.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿CustomPainter funciona con animaciones?

Sí. Pasa un AnimationController como parámetro, añade ..addListener(notifyListeners) en initState y el painter se repintará cada frame de la animación.

¿Cómo hago hit testing con CustomPainter?

Sobrescribe hitTest(Offset position) en el painter y devuelve true si la posición está dentro de un área interactiva. Luego envuelve el CustomPaint en un GestureDetector.

¿Puedo usar CustomPainter dentro de un ListView?

Sí, pero define un tamaño explícito con SizedBox o AspectRatio. Sin tamaño acotado, el canvas tendrá dimensión infinita y lanzará un error de layout.