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.