Custom painting
Drawing directly onto a canvas with CustomPainter: shapes, paths, transforms,
and drawVertices — which is also the entry point to software-projected 3D.
Contents
- When to paint
- CustomPainter
- Paint
- Paths
- Transforms
- Gradients and shaders
- drawVertices
- Faking 3D with transforms
- Animating a painter
- Hit testing
- Exporting to an image
- Performance
When to paint
| Situation | Approach |
|---|---|
| Composition of existing widgets gets the result | Use widgets — they are easier to read and to test |
| Charts, gauges, waveforms, signature pads, custom progress | CustomPainter |
| Per-pixel effects, procedural texture, raymarching | Fragment shader — see Fragment shaders |
| Real meshes, models, cameras, lighting | A 3D renderer — see 3D rendering overview |
Custom painting bypasses the widget layer entirely, so you lose accessibility, hit testing, and text layout unless you implement them. Use it for graphics, not for interface elements that a widget could express.
CustomPainter
class RingPainter extends CustomPainter {
const RingPainter({required this.progress, required this.colour});
final double progress; // 0..1
final Color colour;
@override
void paint(Canvas canvas, Size size) {
final centre = size.center(Offset.zero);
final radius = size.shortestSide / 2 - 8;
final track = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 12
..color = colour.withValues(alpha: 0.2);
final arc = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 12
..strokeCap = StrokeCap.round
..color = colour;
canvas.drawCircle(centre, radius, track);
canvas.drawArc(
Rect.fromCircle(center: centre, radius: radius),
-math.pi / 2, // start at 12 o'clock
progress * 2 * math.pi, // sweep
false,
arc,
);
}
@override
bool shouldRepaint(RingPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.colour != colour;
}
CustomPaint(
size: const Size.square(120),
painter: RingPainter(progress: 0.72, colour: scheme.primary),
)
shouldRepaint is not optional in practice — returning true unconditionally
repaints every frame the parent rebuilds. Compare only the fields that affect
the drawing.
CustomPaint can also take a foregroundPainter (drawn over its child) and a
child, which is how you overlay decoration on a widget subtree.
Paint
| Property | Effect |
|---|---|
style | fill or stroke |
strokeWidth, strokeCap, strokeJoin | Stroke geometry |
color | Solid colour |
shader | Gradient or image, overrides color |
blendMode | How the source combines with the destination |
maskFilter | Blur — MaskFilter.blur(BlurStyle.normal, sigma) |
isAntiAlias | On by default; turning it off is faster and uglier |
imageFilter | Filter applied to the source |
Allocate Paint objects once as fields rather than in paint() when they do
not change — paint runs every frame of an animation.
Paths
final path = Path()
..moveTo(0, size.height)
..lineTo(size.width * 0.3, size.height * 0.4)
..quadraticBezierTo(size.width * 0.5, size.height * 0.1,
size.width * 0.7, size.height * 0.5)
..cubicTo(size.width * 0.8, size.height * 0.7,
size.width * 0.9, size.height * 0.3,
size.width, size.height * 0.6)
..lineTo(size.width, size.height)
..close();
canvas.drawPath(path, fillPaint);
Combine paths with Path.combine(PathOperation.difference, a, b) for cut-outs.
PathMetrics walks a path, which is how you animate a line being drawn or place
an object along a curve:
final metric = path.computeMetrics().first;
final partial = metric.extractPath(0, metric.length * progress);
canvas.drawPath(partial, strokePaint);
final tangent = metric.getTangentForOffset(metric.length * progress)!;
canvas.drawCircle(tangent.position, 6, dotPaint);
Transforms
The canvas carries a transform stack. Always pair save with restore:
canvas.save();
canvas.translate(centre.dx, centre.dy);
canvas.rotate(angle);
canvas.scale(1.2);
canvas.drawRRect(rrect, paint);
canvas.restore();
canvas.transform(matrix.storage) applies a full Matrix4, which is how you
introduce perspective.
Gradients and shaders
final paint = Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF1F4FD8), Color(0xFF00D2FF)],
).createShader(Offset.zero & size);
RadialGradient and SweepGradient work the same way. ImageShader tiles an
image, and is required when texturing vertices:
final shader = ImageShader(
image,
TileMode.clamp,
TileMode.clamp,
Matrix4.identity().storage,
);
For a fully custom per-pixel shader, see Fragment shaders.
drawVertices
drawVertices rasterises triangles with per-vertex positions, texture
coordinates, and colours. It is the lowest-level geometry primitive available on
the normal canvas, and the basis of software-projected 3D.
final vertices = Vertices(
VertexMode.triangles,
positions, // List<Offset>, 3 per triangle
textureCoordinates: uvs, // List<Offset> into the image
colors: colours, // List<Color>, blended per vertex
);
canvas.drawVertices(vertices, BlendMode.srcOver, paint..shader = imageShader);
A minimal projection pipeline on top of it:
- Hold the model as vertices in object space plus triangle indices.
- Transform each vertex by model, view, and projection matrices (
Matrix4). - Divide by
wto get normalised device coordinates, then map to screen pixels. - Drop back-facing triangles by testing the sign of the 2D cross product of the projected edges.
- Sort remaining triangles back-to-front and draw — there is no depth buffer here, so painter's order is all you get.
- Shade per vertex, for example with a Lambert term from a fixed light direction, and pass the result as vertex colours.
This is genuinely useful for a few hundred triangles: a rotating logo, a low-poly globe, a 3D bar chart. It runs the transform on the CPU in Dart every frame, so it does not scale to real models — for those, use Flutter GPU and flutter_scene.
Faking 3D with transforms
For card flips and perspective tilts, no projection code is needed:
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001) // perspective: smaller value = weaker
..rotateY(angle),
child: const Card(child: ...),
)
setEntry(3, 2, value) is the perspective term. This is real 3D projection of a
flat quad — it just has no depth testing, lighting, or geometry. For flips,
switch the child when angle passes pi / 2 so you do not render the back of
the front face.
Animating a painter
Pass the animation to super.repaint rather than rebuilding the widget. The
painter is then repainted directly by the animation, skipping build and layout
entirely:
class SpinnerPainter extends CustomPainter {
SpinnerPainter(this.animation) : super(repaint: animation);
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
final t = animation.value;
// ...
}
@override
bool shouldRepaint(SpinnerPainter oldDelegate) => false;
}
This is the single biggest performance difference between a good and a naive custom-painted animation.
Hit testing
@override
bool hitTest(Offset position) => _path.contains(position);
Return false outside your shape so taps fall through to what is underneath.
For interactive graphics, wrap the CustomPaint in a GestureDetector and do
your own coordinate mapping — remember to invert any transform you applied.
Exporting to an image
final boundary = _key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ImageByteFormat.png);
Wrap the subtree in RepaintBoundary(key: _key, child: ...). Useful for share
sheets and thumbnails. Do it off the critical path — it is a synchronous GPU
readback.
Performance
- Implement
shouldRepainthonestly. - Hoist
Paint,Path, and gradient shaders into fields; rebuilding a path every frame shows up quickly. - Wrap the
CustomPaintin aRepaintBoundaryso its repaints stay local. - Prefer one
drawPathover hundreds ofdrawLinecalls; batch where you can. - Avoid
saveLayer— blend modes and blurs inside a painter are as expensive here as anywhere else, see Rendering pipeline. - Profile in profile mode and watch the raster thread, not just the UI thread.
Next: Fragment shaders.