What is CustomPainter and when would you use it?
3 min read
Widgets & UI
A CustomPainter has two jobs:
| Method | What it does | Don't forget |
|---|---|---|
paint(canvas, size) | Issue draw commands (drawCircle, drawPath, drawRect, drawText…) | Use the given size, not constants |
shouldRepaint(old) | Tell Flutter whether the painter actually changed | Return false when nothing relevant changed — saves a paint pass |
CustomPaint hosts it: CustomPaint(size: ..., painter: ..., foregroundPainter: ..., child: ...). The child still renders normally; the painter draws behind (painter) or on top (foregroundPainter).
Code in action — circular progress
class CircleProgress extends CustomPainter {
CircleProgress({required this.progress, required this.color});
final double progress; // 0.0 → 1.0
final Color color;
@override
void paint(Canvas canvas, Size size) {
final centre = size.center(Offset.zero);
final radius = size.shortestSide / 2;
// Track
canvas.drawCircle(
centre,
radius,
Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 10,
);
// Progress arc
canvas.drawArc(
Rect.fromCircle(center: centre, radius: radius),
-pi / 2, // start at top
2 * pi * progress, // sweep angle
false,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 10
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(CircleProgress old) =>
progress != old.progress || color != old.color;
}
// Use it
CustomPaint(
size: const Size.square(120),
painter: CircleProgress(progress: 0.7, color: Colors.indigo),
);
When to reach for CustomPainter
| Situation | Use |
|---|---|
| Charts, graphs, sparklines | ✅ CustomPainter |
| Custom progress / loading indicators | ✅ |
| Free-form drawing / signature pads | ✅ |
| Geometry that can't be expressed with widgets (arcs, wedges, custom shapes) | ✅ |
| Decorative line behind a row of items | ✅ |
| A button / common shape you could compose from widgets | ❌ Just use widgets |
| Heavy animated visuals | ✅ but wrap in RepaintBoundary |
Common mistakes to avoid
// ❌ shouldRepaint(old) => true — paints every frame
@override
bool shouldRepaint(_) => true;
// ✅ Compare actual inputs
// ❌ shouldRepaint(_) => false — but you changed inputs
// painter never re-renders; users see stale UI
// ❌ Allocating Paint objects in paint()
final p = Paint(); // every frame allocates — GC pressure
// ✅ Reuse Paint where possible, or build them in the constructor
// ❌ Hard-coding sizes
canvas.drawCircle(const Offset(50, 50), 30, paint);
// ✅ Use the size parameter: size.center(Offset.zero), size.shortestSide / 2
// ❌ Not wrapping in RepaintBoundary for animations
// Every frame repaints the painter AND its ancestors. Add a RepaintBoundary.
// ❌ Drawing text without a TextPainter
// canvas.drawText doesn't exist — use TextPainter to lay out then paint
Interview follow-ups
-
What does
shouldRepaintdo, and what happens if you return the wrong answer? Flutter calls it on rebuild to decide whether to invokepaintagain. Returntruealways = wasted CPU every frame. Returnfalsewhen inputs did change = stale visuals. Compare every field that affects the drawing. -
Why wrap heavy
CustomPaints in aRepaintBoundary? Without it, any repaint inside the painter dirties the parent layer too. ARepaintBoundarygives the painter its own layer, so repainting it doesn't force the ancestors to repaint. -
How do you draw text inside a CustomPainter? With
TextPainter:TextPainter(text: ..., textDirection: ...)..layout(); painter.paint(canvas, offset). The Canvas doesn't have a directdrawText. -
Is CustomPainter slower than using regular widgets? Not inherently — it's actually a lower-level path. The risk is misusing it (always repainting, allocating Paints every frame, lots of overdraw). A correct painter is usually faster than a deeply nested widget tree achieving the same visual.
How helpful was this content?
Please sign in to rate this article.