Skip to content
Blog

CustomPainter for Canvas Drawing in Flutter: A Practical Guide

Learn how to draw custom graphics in Flutter with CustomPaint, CustomPainter, and the Canvas API — from your first circle to performant, animated visualizations.

Published on • October 28, 2026

AI Assistant

Flutter’s widget catalog covers a lot of ground, but eventually every app hits a visual that doesn’t exist as a widget: a custom chart, a signature pad, a decorative gradient blob, an animated progress ring, a game sprite. That’s where CustomPaint and CustomPainter come in — Flutter’s direct interface to the Canvas API.

The CustomPaint Widget

At its core, CustomPaint is a widget that gives you a Canvas and a Size to draw on:

CustomPaint(
  size: Size.zero,       // takes parent's size by default
  painter: MyPainter(),
  foregroundPainter: RingPainter(),
  child: const SizedBox.expand(),
);

Three properties matter:

  • painter — drawn behind the child.
  • foregroundPainter — drawn in front of the child.
  • child — optional; if present, it participates in layout, and the canvas wraps around it. If omitted, you control the size via the size parameter.

A common pattern is to give CustomPaint an SizedBox.expand() child so the painter fills whatever space its parent grants it — this makes your drawing automatically responsive.

Anatomy of a CustomPainter

A CustomPainter subclass implements two methods:

class StarPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.amber
      ..style = PaintingStyle.fill;

    final center = Offset(size.width / 2, size.height / 2);
    canvas.drawCircle(center, size.width / 4, paint);
  }

  @override
  bool shouldRepaint(covariant StarPainter oldDelegate) => false;
}

paint(Canvas, Size)

The canvas parameter is your drawing surface. The most important methods:

  • drawLine, drawRect, drawRRect, drawCircle, drawOval, drawArc — primitive shapes.
  • drawPath — arbitrary shapes from a Path.
  • drawImage, drawImageRect — raster images.
  • drawParagraph — text (you build a Paragraph with TextPainter).
  • save() / restore() — push and pop the transform/clip state, like a graphics stack.
  • translate, rotate, scale — transforms that apply to everything drawn after them until restore().
  • clipRect, clipRRect, clipPath — restrict the drawing region.

The Paint object

Paint configures how things are drawn:

final paint = Paint()
  ..color = const Color(0xFFE91E63)
  ..style = PaintingStyle.stroke   // fill vs stroke
  ..strokeWidth = 4.0
  ..strokeCap = StrokeCap.round    // line endings
  ..strokeJoin = StrokeJoin.round  // corner joins
  ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3)
  ..shader = const LinearGradient(
    colors: [Colors.blue, Colors.purple],
  ).createShader(rect);

Chaining with .. (cascade notation) is idiomatic Dart and keeps painter code compact.

Drawing a Custom Shape: Step by Step

Let’s build a simple star polygon using Path:

class StarPainter extends CustomPainter {
  final int points;
  final Color color;
  StarPainter({required this.points, required this.color});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = color;
    final center = size.center(Offset.zero);
    final outerRadius = size.shortestSide / 2;
    final innerRadius = outerRadius * 0.4;

    final path = Path();
    for (int i = 0; i < points * 2; i++) {
      final radius = i.isEven ? outerRadius : innerRadius;
      final angle = (math.pi / points) * i - math.pi / 2;
      final point = Offset(
        center.dx + radius * math.cos(angle),
        center.dy + radius * math.sin(angle),
      );
      if (i == 0) {
        path.moveTo(point.dx, point.dy);
      } else {
        path.lineTo(point.dx, point.dy);
      }
    }
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant StarPainter old) =>
      old.points != points || old.color != color;
}

Use it with:

CustomPaint(
  size: const Size(200, 200),
  painter: StarPainter(points: 5, color: Colors.amber),
);

The same Path API scales to much richer shapes — wave functions sampled into line segments, bezier curves with quadraticBezierTo and cubicTo, or closed regions built with addOval, addRRect, and addPath.

shouldRepaint: The Performance Gate

shouldRepaint decides whether the canvas re-executes paint when the widget rebuilds. Returning true unconditionally means redrawing on every rebuild — expensive for complex paths.

The rule: compare the old delegate’s properties with the new painter’s properties, and return true only if something that affects the drawing changed. If your painter is immutable and constructed with all its inputs, this is a simple field comparison as shown above.

A frequent mistake is capturing values from an enclosing State without passing them into the painter, then returning false from shouldRepaint — the UI then never updates. Pass every visual input as a constructor parameter and compare them.

Animating with repaint: Listenable

For animations, don’t rebuild the widget tree — hand the painter a Listenable (like an AnimationController) and let it repaint:

class SweeperPainter extends CustomPainter {
  final Animation<double> progress;
  SweeperPainter(this.progress) : super(repaint: progress);

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 12
      ..strokeCap = StrokeCap.round;

    paint.color = Colors.blue.withOpacity(0.2);
    canvas.drawCircle(rect.center, rect.width / 2, paint);

    paint.color = Colors.blue;
    canvas.drawArc(
      rect.deflate(6),
      -math.pi / 2,
      2 * math.pi * progress.value,
      false,
      paint,
    );
  }

  @override
  bool shouldRepaint(SweeperPainter old) => old.progress != progress;
}

Passing repaint: progress to the CustomPainter base constructor subscribes the render object to the animation. Each tick calls only paint() — no setState, no rebuild. This is the same mechanism Flutter itself uses for its built-in animated widgets, and it keeps 60/120fps animations smooth.

Text on Canvas

Drawing text requires TextPainter:

final tp = TextPainter(
  text: const TextSpan(
    text: '62%',
    style: TextStyle(color: Colors.black, fontSize: 32, fontWeight: FontWeight.bold),
  ),
  textDirection: TextDirection.ltr,
)..layout();

tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2));

Always call layout() before paint(), and cache the TextPainter if you draw the same text repeatedly — layout is not free.

When to Use CustomPainter (and When Not To)

CustomPainter shines for:

  • Charts and data visualizations (lines, bars, areas).
  • Decorative graphics and custom shapes.
  • Signature and drawing pads.
  • Progress indicators and overlays.
  • Anything drawn many times per frame where widget overhead would add up.

Reach for regular widgets instead when:

  • You need hit-testing, gestures, or accessibility semantics on individual elements — widgets give you all of that for free; on a canvas you’d re-implement it.
  • The visual is a simple box or rounded rectangle — Container with BoxDecoration (including BoxDecoration gradients and shape: BoxShape.circle) is less code and better optimized.

A hybrid approach works well: draw the static or decorative layer in a CustomPainter, and overlay real widgets for the interactive parts.

Performance Tips

  1. Cache your Paint and Path objects when they don’t change — constructing them every frame adds GC pressure.
  2. Use repaint: Listenable for animation instead of setState.
  3. Avoid saveLayer when possible — it forces an offscreen buffer and is the single most expensive canvas call. Use it only when you need opacity or complex blend effects on a group.
  4. Clip minimally. clipPath with a complex path is costly; prefer shaping the path itself.
  5. Profile with DevTools — the performance overlay shows raster thread time, which is where custom painting work shows up.

Conclusion

The Canvas API in Flutter — reached through CustomPaint and CustomPainter — is deliberately low-level: a handful of draw calls, a transform stack, and a rich Path builder. That simplicity is the point. With Paint for styling, Path for geometry, TextPainter for text, and the repaint Listenable hook for animation, you can draw virtually anything, and blend it seamlessly with the widget tree around it.

Start with a circle, graduate to paths, wire up an AnimationController, and before long the canvas stops being intimidating and starts being a tool.

Further Reading