← Articles

Building custom painters in Flutter

By Ann Tech · 14 June 2025

Flutter's CustomPainter lets you draw anything directly on a Canvas — charts, progress rings, custom shapes, decorative backgrounds. Here is how to build custom painters efficiently.

The basics

class CircleProgressPainter extends CustomPainter {
  CircleProgressPainter({required this.progress, required this.color});
  final double progress; // 0.0 to 1.0
  final Color color;

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

    // Background track
    canvas.drawCircle(
      center,
      radius,
      Paint()
        ..color = color.withOpacity(0.2)
        ..style = PaintingStyle.stroke
        ..strokeWidth = 8,
    );

    // Progress arc
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      -math.pi / 2, // Start at top
      2 * math.pi * progress, // Sweep angle
      false,
      Paint()
        ..color = color
        ..style = PaintingStyle.stroke
        ..strokeWidth = 8
        ..strokeCap = StrokeCap.round,
    );
  }

  @override
  bool shouldRepaint(CircleProgressPainter old) =>
      old.progress != progress || old.color != color;
}

// Usage:
CustomPaint(
  painter: CircleProgressPainter(progress: 0.75, color: Colors.blue),
  size: const Size(100, 100),
)

shouldRepaint

shouldRepaint controls whether Flutter repaints on rebuild. Return true only if the visual output has changed:

@override
bool shouldRepaint(MyPainter old) {
  // Only repaint if data that affects visuals changed
  return old.progress != progress
      || old.color != color
      || old.strokeWidth != strokeWidth;
}

// Never do this — causes repaints on every rebuild even if nothing changed:
// @override bool shouldRepaint(covariant CustomPainter old) => true;

Drawing primitives

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

  // Rectangle
  canvas.drawRect(const Rect.fromLTWH(10, 10, 100, 60), paint);

  // Rounded rectangle
  canvas.drawRRect(
    RRect.fromRectAndRadius(
      const Rect.fromLTWH(10, 80, 100, 60),
      const Radius.circular(12),
    ),
    paint,
  );

  // Line
  canvas.drawLine(
    const Offset(0, size.height / 2),
    Offset(size.width, size.height / 2),
    Paint()..color = Colors.grey..strokeWidth = 1,
  );

  // Path (arbitrary shape)
  final path = Path()
    ..moveTo(size.width / 2, 0)
    ..lineTo(size.width, size.height)
    ..lineTo(0, size.height)
    ..close();
  canvas.drawPath(path, Paint()..color = Colors.orange);

  // Text
  final textPainter = TextPainter(
    text: TextSpan(text: '75%', style: const TextStyle(color: Colors.black, fontSize: 16)),
    textDirection: TextDirection.ltr,
  )..layout();
  textPainter.paint(canvas, Offset(
    (size.width - textPainter.width) / 2,
    (size.height - textPainter.height) / 2,
  ));
}

Animating custom painters

class AnimatedProgressRing extends StatefulWidget {
  const AnimatedProgressRing({super.key, required this.progress});
  final double progress;

  @override
  State<AnimatedProgressRing> createState() => _AnimatedProgressRingState();
}

class _AnimatedProgressRingState extends State<AnimatedProgressRing>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );
    _animation = Tween<double>(begin: 0, end: widget.progress)
        .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
    _controller.forward();
  }

  @override
  void didUpdateWidget(AnimatedProgressRing old) {
    super.didUpdateWidget(old);
    if (old.progress != widget.progress) {
      _animation = Tween<double>(begin: _animation.value, end: widget.progress)
          .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
      _controller
        ..reset()
        ..forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      builder: (_, __) => CustomPaint(
        painter: CircleProgressPainter(
          progress: _animation.value,
          color: Theme.of(context).colorScheme.primary,
        ),
        size: const Size(80, 80),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Canvas save/restore for transformations

void paint(Canvas canvas, Size size) {
  // Save state before transformation
  canvas.save();

  // Rotate around center
  canvas.translate(size.width / 2, size.height / 2);
  canvas.rotate(math.pi / 4); // 45 degrees
  canvas.translate(-size.width / 2, -size.height / 2);

  // Draw rotated content
  canvas.drawRect(
    Rect.fromLTWH(size.width * 0.25, size.height * 0.25,
        size.width * 0.5, size.height * 0.5),
    Paint()..color = Colors.blue,
  );

  // Restore original transform
  canvas.restore();

  // This draws without rotation
  canvas.drawCircle(Offset(size.width / 2, size.height / 2), 4, Paint()..color = Colors.red);
}

Common pitfalls

Creating Paint objects inside paint() on every frame. Paint() allocates a new object. For animated painters called 60 times per second, cache the paint objects as fields:

class _Painter extends CustomPainter {
  final _trackPaint = Paint()..color = Colors.grey..style = PaintingStyle.stroke..strokeWidth = 8;
  final _progressPaint = Paint()..style = PaintingStyle.stroke..strokeWidth = 8;
  // ...
}

Not calling canvas.restore() after canvas.save(). Unbalanced save/restore causes cumulative transform drift. Always pair them — or use canvas.saveLayer() for operations that need compositing.

Drawing outside the bounds. CustomPaint clips to its size by default. Drawing outside size is clipped. If you need to draw outside the widget's bounds (for shadow overflow), use CustomPainter with willChange: true and wrap in an OverflowBox.

Sign in to like, dislike, or report.

Building custom painters in Flutter — ANN Tech