CustomPainter and custom drawing

Flutter · Expert · 14 min

When no widget fits the design, drawing on the canvas gives full control with excellent performance.

`CustomPaint` hands you a `Canvas` to draw on through a `CustomPainter`:

  • paint(Canvas canvas, Size size) draws using canvas.drawLine, drawRect, drawRRect, drawCircle, drawArc, drawPath, drawImage, and text via TextPainter. Coordinates start at the top-left of the given size.
  • `Paint` objects describe style: color, strokeWidth, style (fill or stroke), strokeCap, shader for gradients, maskFilter for blur.
  • `Path` builds complex shapes with moveTo, lineTo, quadraticBezierTo, cubicTo, arcTo and addOval, combined with Path.combine.
  • canvas.save() / restore() with translate, rotate, scale transform drawing; clipRect/clipPath clip it.

`shouldRepaint(oldDelegate)` decides whether a new painter instance requires repainting; compare the inputs and return false when nothing changed.

For animation, pass the Animation (any Listenable) as the painter's `repaint` argument: MyPainter(progress: controller) : super(repaint: controller). The painter then repaints on every tick without rebuilding any widgets — the most efficient way to animate a drawing.

Give CustomPaint a size via size: or a child or parent constraints; otherwise it may be zero-sized. Wrap it in RepaintBoundary if it repaints often. For hit testing on custom shapes, override hitTest. For accessibility, provide semanticsBuilder or wrap it in Semantics with a label, since the canvas has no semantic content.

Example

import 'dart:math' as math;
import 'package:flutter/material.dart';
class RingPainter extends CustomPainter {
  RingPainter({required this.progress, required this.color}) : super(repaint: progress);
  final Animation<double> progress;     // repaints on every tick, no widget rebuild
  final Color color;
  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2 - 6;
    Paint stroke(Color c) => Paint()
      ..style = PaintingStyle.stroke..strokeWidth = 12..strokeCap = StrokeCap.round..color = c;
    final track = stroke(color.withValues(alpha: 0.2)), arc = stroke(color);
    canvas.drawCircle(center, radius, track);
    canvas.drawArc(Rect.fromCircle(center: center, radius: radius),
        -math.pi / 2, 2 * math.pi * progress.value, false, arc);
  }

  @override
  bool shouldRepaint(RingPainter old) => old.color != color || old.progress != progress;
}

// Usage inside a State with an AnimationController `_c`:
// RepaintBoundary(child: CustomPaint(size: const Size.square(120),
//     painter: RingPainter(progress: _c, color: Colors.teal)))

Remember

  • Draw with Canvas + Paint + Path in a CustomPainter
  • Pass an Animation as repaint to animate without rebuilding widgets
  • Implement shouldRepaint carefully and add semantics

Watch out for

  • Returning true from shouldRepaint unconditionally, repainting even when nothing changed.

Check yourself

  1. What is the benefit of passing an AnimationController as a CustomPainter's `repaint` argument?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: api.flutter.dev/flutter/widgets/CustomPaint-class.html