Custom Painting in Flutter: CustomPainter, Canvas and Animated Graphics
Custom painting in Flutter with CustomPainter: Canvas, Paint and Path basics, an animated progress ring, a small bar chart, hit testing and RepaintBoundary.

Sooner or later a designer hands you something no widget can build. A progress ring with a gradient and rounded ends. A tiny sparkline in a list tile. A gauge with tick marks. You can try to fake it with stacked Containers and rotated boxes, or you can open a Canvas and draw it.
CustomPainter is less scary than it looks. It’s a class with two methods and an API that will feel familiar if you’ve ever used an HTML canvas or Android’s Canvas. Here’s what I’ve learned using it, organized as the lessons I’d want someone to tell me before my first painter.
1. A painter is two methods and a widget
class DotPainter extends CustomPainter {
const DotPainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
canvas.drawCircle(size.center(Offset.zero), size.shortestSide / 2, paint);
}
@override
bool shouldRepaint(DotPainter oldDelegate) => oldDelegate.color != color;
}
// Usage:
CustomPaint(
size: const Size.square(24),
painter: DotPainter(Colors.teal),
)
paint receives a Canvas and the Size you have to work with. The origin is the top-left corner, x goes right, y goes down. Everything you draw is relative to that box.
shouldRepaint is called when a new painter instance replaces the old one, which happens every time the parent rebuilds. Return true only if something that affects the drawing actually changed. Returning true unconditionally works, but repaints on every rebuild for no reason.
The CustomPaint widget sizes itself from its child if it has one, otherwise from size, otherwise as small as its constraints allow. A painter that draws nothing is often a painter with a zero-size canvas. Give it a size or tight constraints.
2. Paint is the brush, Path is the shape
Three objects do almost everything:
Canvas: the drawing surface, with methods likedrawLine,drawRect,drawRRect,drawCircle,drawArcanddrawPath, plus transforms (translate,rotate,scale) andsave/restore.Paint: how to draw. Color,style(fill or stroke),strokeWidth,strokeCap,shaderfor gradients,maskFilterfor blur.Path: an arbitrary shape built frommoveTo,lineTo,quadraticBezierTo,cubicTo,arcToandclose.
A sparkline is a good first Path:
class SparklinePainter extends CustomPainter {
SparklinePainter(this.values, this.color);
final List<double> values;
final Color color;
@override
void paint(Canvas canvas, Size size) {
if (values.length < 2) return;
final maxV = values.reduce(math.max);
final minV = values.reduce(math.min);
final range = (maxV - minV) == 0 ? 1.0 : maxV - minV;
final dx = size.width / (values.length - 1);
final path = Path();
for (var i = 0; i < values.length; i++) {
final x = i * dx;
final y = size.height - (values[i] - minV) / range * size.height;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeJoin = StrokeJoin.round,
);
}
@override
bool shouldRepaint(SparklinePainter old) =>
old.color != color || !listEquals(old.values, values);
}
(math is dart:math and listEquals comes from package:flutter/foundation.dart.)
Notice the y-axis flip: the highest value should be at the top, and the top is y = 0. You’ll do this flip in every chart you draw.
3. A progress ring is just two arcs
Here’s the one I end up writing on almost every project that has goals, uploads or onboarding steps:
class ProgressRingPainter extends CustomPainter {
ProgressRingPainter({
required this.progress,
required this.color,
required this.trackColor,
this.strokeWidth = 10,
}) : super(repaint: progress);
final Animation<double> progress;
final Color color;
final Color trackColor;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = (size.shortestSide - strokeWidth) / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
final base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, base..color = trackColor);
const start = -math.pi / 2; // 12 o'clock
final sweep = 2 * math.pi * progress.value.clamp(0.0, 1.0);
canvas.drawArc(rect, start, sweep, false, base..color = color);
}
@override
bool shouldRepaint(ProgressRingPainter old) =>
old.progress != progress ||
old.color != color ||
old.trackColor != trackColor ||
old.strokeWidth != strokeWidth;
}
Angles are in radians, and zero points to 3 o’clock, so starting at -pi / 2 puts the beginning at the top. Subtracting the stroke width from the diameter keeps the stroke from being clipped at the edges, which is a mistake I’ve made more than once.
Want the gradient the designer asked for? Set shader on the paint, for example SweepGradient(colors: [...]).createShader(rect). A sweep gradient also starts at 3 o’clock, so give it a GradientRotation(-math.pi / 2) transform to line it up with the arc. For tick marks or labels around the ring, canvas.save(), translate to the center, rotate a step at a time and restore() when you’re done. Forgetting the restore is how a chart ends up drawn sideways.
4. Animate with repaint:, not setState
Look at the constructor again: super(repaint: progress). Passing a Listenable (an Animation, a ValueNotifier, a ChangeNotifier) to repaint tells Flutter to repaint whenever it notifies. Only paint runs. No widget rebuild, no layout.
class _GoalRingState extends State<GoalRing> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
late final Animation<double> _progress = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
@override
void initState() {
super.initState();
_controller.animateTo(widget.value);
}
@override
void didUpdateWidget(GoalRing oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) _controller.animateTo(widget.value);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return RepaintBoundary(
child: CustomPaint(
size: const Size.square(120),
painter: ProgressRingPainter(
progress: _progress,
color: scheme.primary,
trackColor: scheme.surfaceContainerHighest,
),
),
);
}
}
The alternative, calling setState on every tick and creating a new painter with a plain double, works but rebuilds the widget subtree 60 or more times a second. The repaint: approach skips straight to the paint phase. If the lifecycle methods above look unfamiliar, I walk through them in the Flutter widget lifecycle explained.
5. Wrap animated painters in a RepaintBoundary
Flutter groups render objects into layers. When one thing in a layer needs repainting, everything else in that layer repaints with it. An animated ring sitting in a card next to text and images can drag that whole card into every frame.
A RepaintBoundary gives its child its own layer, so the ring repaints alone. That’s why it’s in the build method above. Don’t sprinkle them everywhere, since each layer has a memory cost, but around anything that animates continuously they’re almost always worth it. The DevTools “highlight repaints” option shows you exactly what’s repainting, and it’s the first thing I turn on when fixing jank.
CustomPaint also has isComplex and willChange hints that help the engine decide whether to cache the painting. They’re hints, not guarantees; I set willChange: true on painters that animate and leave the rest alone.
6. Hit testing is your job
A painter draws pixels; it doesn’t know that the third bar in your chart is a bar. If you want taps on painted shapes, there are two routes.
The simple one: wrap the CustomPaint in a GestureDetector and map the tap position back into your data using the same math you used to draw.
GestureDetector(
onTapUp: (details) {
final barWidth = chartWidth / values.length;
final index = (details.localPosition.dx / barWidth).floor();
if (index >= 0 && index < values.length) onBarTapped(index);
},
child: CustomPaint(
size: Size(chartWidth, 160),
painter: BarChartPainter(values),
),
)
The second: override hitTest(Offset position) on the painter. Return true if the position counts as a hit, false if not. That’s useful for non-rectangular shapes, like a ring where taps in the empty middle should pass through. Path.contains makes this straightforward if you kept the path around.
Either way, keep the layout math (bar positions, scales) in one place both the painter and the gesture code can use, or they’ll drift apart.
And don’t forget that painted content is invisible to screen readers. Wrap it in Semantics with a label like “Weekly goal, 70 percent complete”. More on that in accessibility in Flutter apps.
7. Know when not to paint
Custom painting is the right tool for small, specific, animated or data-driven graphics. It’s the wrong tool for:
- Full charting. Axes, labels, legends, tooltips, zooming and accessibility add up quickly. A package like
fl_charthas already solved most of it. I paint my own only when the design is simple or very unusual. - Static illustrations and icons. If a designer drew it in Figma, export it as SVG and render it with
flutter_svg. Translating a complex illustration intoPathcalls by hand is slow and fragile. - Text-heavy graphics. You can draw text with
TextPainter, but you lose wrapping, selection and accessibility that aTextwidget gives you for free. Often aStackwith a painter behind real widgets is the better split.
Takeaways
- A
CustomPainterispaintplusshouldRepaint; makeshouldRepaintcompare the fields that matter. Paintdescribes how to draw,Pathdescribes what to draw, and y grows downward.- Animate by passing a
Listenabletosuper(repaint:)so only the paint phase runs. - Put a
RepaintBoundaryaround painters that animate continuously. - Handle taps with a
GestureDetectororhitTest, and addSemanticsfor screen readers. - Reach for a chart package or SVG when the drawing gets complex.
Start with a progress ring. Once that works, every “can we make it look like this?” from a designer gets a lot less intimidating.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).