CustomPainter in Flutter: solved exercise

CustomPainter in Flutter: solved exercise

When a standard widget is not enough, CustomPainter gives you direct access to the Canvas to draw shapes, paths, and text with full pixel control. It is used in charts, animated indicators, digital signatures, and custom visual effects.

Problem statement

Implement two painters:

  1. RingProgressPainter: a circular progress ring (like health or loading indicators).
  2. BarChartPainter: a simple bar chart with value labels.

Connect both to a Slider so the value updates in real time.

Dependencies

Flutter SDK only — no external packages.

Solution: 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;

    // Full track
    canvas.drawCircle(center, radius, trackPaint);

    // Progress arc (starts at 12 o'clock: -π/2)
    final sweepAngle = 2 * math.pi * progress;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2,
      sweepAngle,
      false,
      progressPaint,
    );

    // Center text with percentage
    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;
}

Solution: 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; // normalized 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;

      // Bar with rounded top corners
      final rrect = RRect.fromRectAndCorners(
        Rect.fromLTWH(x, top, barWidth, barHeight),
        topLeft: const Radius.circular(4),
        topRight: const Radius.circular(4),
      );
      canvas.drawRRect(rrect, barPaint);

      // Label below the bar
      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;
}

Solution: full screen

 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 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('CustomPainter Demo')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            // Progress ring
            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),
            // Bar chart
            SizedBox(
              height: 160,
              child: CustomPaint(
                size: Size.infinite,
                painter: BarChartPainter(
                  values: _barValues,
                  labels: _barLabels,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Expected result

  • The ring displays the percentage in the center and the green arc grows as you move the slider.
  • The bar chart shows 6 columns with day labels.
  • Both repaint fluidly thanks to shouldRepaint.

How shouldRepaint works

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

Flutter calls paint only when shouldRepaint returns true. Comparing only the changed fields avoids unnecessary repaints and keeps rendering at 60/120 fps.

Common mistakes

  • Not implementing shouldRepaint properly: always returning true triggers a repaint every frame even when nothing changed.
  • Creating Paint() inside paint(): Paint objects are lightweight but creating them on every repaint adds up. Move them to final fields if the painter is not stateful.
  • Assuming arc origin is center: canvas.drawArc uses a Rect, not a center point. Always compute Offset(size.width/2, size.height/2) explicitly.

When to use CustomPainter

SituationBest option
Custom chartCustomPainter
Complex animationCustomPainter + AnimationController
Full production chartfl_chart / syncfusion_flutter_charts
Simple decorative shapesContainer + BoxDecoration

Practical use

CustomPainter is used for onboarding progress indicators, analytics charts, on-screen signatures, heat maps, and any effect that standard widgets cannot achieve.

Guided practice and next step

FAQ

Does CustomPainter work with animations?

Yes. Pass an AnimationController as a parameter, add ..addListener(notifyListeners) in initState, and the painter will repaint on every animation frame.

How do I add hit testing to CustomPainter?

Override hitTest(Offset position) in the painter and return true if the position falls within an interactive area. Then wrap CustomPaint in a GestureDetector.

Can I use CustomPainter inside a ListView?

Yes, but give it an explicit size with SizedBox or AspectRatio. Without a bounded size the canvas will have infinite dimensions and throw a layout error.