Skip to main content

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

SituationApproach
Composition of existing widgets gets the resultUse widgets — they are easier to read and to test
Charts, gauges, waveforms, signature pads, custom progressCustomPainter
Per-pixel effects, procedural texture, raymarchingFragment shader — see Fragment shaders
Real meshes, models, cameras, lightingA 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

PropertyEffect
stylefill or stroke
strokeWidth, strokeCap, strokeJoinStroke geometry
colorSolid colour
shaderGradient or image, overrides color
blendModeHow the source combines with the destination
maskFilterBlur — MaskFilter.blur(BlurStyle.normal, sigma)
isAntiAliasOn by default; turning it off is faster and uglier
imageFilterFilter 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:

  1. Hold the model as vertices in object space plus triangle indices.
  2. Transform each vertex by model, view, and projection matrices (Matrix4).
  3. Divide by w to get normalised device coordinates, then map to screen pixels.
  4. Drop back-facing triangles by testing the sign of the 2D cross product of the projected edges.
  5. Sort remaining triangles back-to-front and draw — there is no depth buffer here, so painter's order is all you get.
  6. 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 shouldRepaint honestly.
  • Hoist Paint, Path, and gradient shaders into fields; rebuilding a path every frame shows up quickly.
  • Wrap the CustomPaint in a RepaintBoundary so its repaints stay local.
  • Prefer one drawPath over hundreds of drawLine calls; 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.