You write a CustomPainter, return false from shouldRepaint, add a print to paint() and watch it fire on every frame anyway. The painter isn't broken. shouldRepaint just answers a much narrower question than most people think.

This article covers what actually triggers a repaint, how to see it happening, and the fix for each cause. It also covers the opposite bug: a painter that should repaint but doesn't.


What shouldRepaint really decides

shouldRepaint is called in exactly one situation: the CustomPaint widget was rebuilt and received a new painter instance. Flutter asks the new painter, "compared with the old one, do you look different?" If the answer is false, the swap alone won't trigger a repaint.

It has no say over anything else. Your painter will still repaint when:

  • something else in the same paint layer asks to repaint, or
  • the painter's own repaint Listenable fires.

The first one is where most mystery repaints come from.


How to see repaints

Before fixing anything, confirm what's repainting:

  • Flutter DevTools โ†’ Flutter Inspector โ†’ "Highlight Repaints". Every layer that repaints gets a coloured border that changes colour on each repaint. If your painter's region keeps cycling colours while it should be static, it's repainting.
  • From code, set debugRepaintRainbowEnabled = true; (from package:flutter/rendering.dart) in main() for the same effect.
  • The blunt way: keep a counter in paint() and debugPrint it.

Cause 1: something next to your painter is animating

Flutter doesn't repaint single widgets. It repaints layers. When any render object calls markNeedsPaint(), Flutter walks up the tree to the nearest repaint boundary and repaints everything under that boundary.

So if your CustomPaint sits in the same Row as a spinner, an animated icon or a blinking cursor, every frame of their animation repaints your painter, no matter what shouldRepaint returns.

Here's what a widget test shows with a CustomPaint placed next to a small animated widget, run for 10 frames:

Setup paint() calls over 10 frames
CustomPaint next to an animating sibling 11
Same, with the CustomPaint wrapped in RepaintBoundary 1 (the initial paint)

The fix is to give the painter its own layer:

RepaintBoundary(
  child: CustomPaint(
    size: const Size(300, 200),
    painter: ChartPainter(data),
  ),
)

Now the animation repaints its own region and your painter's output is reused as-is.

A few notes:

  • Scrolling lists already do this. ListView, GridView and the other builder lists wrap each item in a RepaintBoundary by default (addRepaintBoundaries: true).
  • Don't wrap everything. Each boundary is a separate layer with its own memory. Use them where two things change at different rates: a static chart next to a live clock, a canvas next to a loading spinner.
  • You can wrap the animating widget instead of the painter. Either side works, as long as they end up in different layers.

Cause 2: shouldRepaint returns true and the parent rebuilds often

The classic copy-paste painter:

@override
bool shouldRepaint(CustomPainter oldDelegate) => true;

On its own this is harmless. Combine it with a parent that rebuilds often (a setState driven by a scroll listener, a stream, or a timer) and every rebuild creates a new painter that insists it's different.

The fix is to compare the fields that actually affect the drawing:

@override
bool shouldRepaint(ChartPainter oldDelegate) =>
    oldDelegate.data != data || oldDelegate.color != color;

Make sure those comparisons are cheap. shouldRepaint runs on every rebuild, so deep-comparing a 10,000-point list there can cost more than the repaint you're trying to avoid.


Cause 3: animating by rebuilding the widget

This pattern works, but it does more work than necessary:

// Rebuilds the widget subtree on every tick, then repaints
AnimatedBuilder(
  animation: _controller,
  builder: (context, _) => CustomPaint(painter: CirclePainter(_controller.value)),
)

For an animation the painter has to repaint every frame, so that part is expected. The waste is the rebuild: a new widget, a new painter and a shouldRepaint call 60 times a second.

The fix: hand the animation to the painter's repaint argument. The painter then repaints directly when the animation ticks, with no rebuild at all:

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

  @override
  void paint(Canvas canvas, Size size) {
    final radius = size.shortestSide / 2 * animation.value;
    canvas.drawCircle(
      size.center(Offset.zero),
      radius,
      Paint()..color = const Color(0xFF6366F1),
    );
  }

  @override
  bool shouldRepaint(PulsePainter oldDelegate) => oldDelegate.animation != animation;
}

class Pulse extends StatefulWidget {
  const Pulse({super.key});

  @override
  State<Pulse> createState() => _PulseState();
}

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 1),
  )..repeat(reverse: true);

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // build() runs once. The painter repaints on every tick by itself.
    return CustomPaint(size: const Size(120, 120), painter: PulsePainter(_controller));
  }
}

Read animation.value inside paint(), not in the constructor. Reading it in the constructor captures a single frame's value.

To repaint on several sources, combine them with Listenable.merge([animationA, animationB]).

The same idea for drawing apps

Finger-drawing apps often call setState on every onPanUpdate, which rebuilds on every pointer event. A ValueNotifier used as the repaint source avoids that:

class StrokePainter extends CustomPainter {
  StrokePainter(this.points) : super(repaint: points);
  final ValueNotifier<List<Offset>> points;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.black
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round;
    final p = points.value;
    for (var i = 0; i < p.length - 1; i++) {
      canvas.drawLine(p[i], p[i + 1], paint);
    }
  }

  @override
  bool shouldRepaint(StrokePainter oldDelegate) => oldDelegate.points != points;
}

class Sketchpad extends StatefulWidget {
  const Sketchpad({super.key});

  @override
  State<Sketchpad> createState() => _SketchpadState();
}

class _SketchpadState extends State<Sketchpad> {
  final _points = ValueNotifier<List<Offset>>([]);

  @override
  void dispose() {
    _points.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (d) => _points.value = [..._points.value, d.localPosition],
      child: CustomPaint(painter: StrokePainter(_points), size: Size.infinite),
    );
  }
}

In a test, two updates to the notifier produced two repaints and zero rebuilds. Copying the list on every event is fine for short strokes. For very long ones, a ChangeNotifier that appends in place and calls notifyListeners() avoids the copy.


The opposite bug: it doesn't repaint

This one is quieter and more confusing:

class DotsPainter extends CustomPainter {
  DotsPainter(this.dots);
  final List<Offset> dots;

  @override
  void paint(Canvas canvas, Size size) {
    for (final d in dots) {
      canvas.drawCircle(d, 4, Paint());
    }
  }

  @override
  bool shouldRepaint(DotsPainter oldDelegate) => oldDelegate.dots != dots;
}

// Somewhere in a State:
setState(() => dots.add(newDot)); // the new dot never appears

dots.add(...) changes the list in place. The new painter and the old painter hold the same list object, so oldDelegate.dots != dots is false and Flutter skips the repaint. A widget test confirms it: zero paint() calls after the setState.

Fix it by creating a new list (dots = [...dots, newDot]), or by driving the painter with a repaint notifier as shown above.


Checklist

When a painter repaints too often:

  1. Turn on Highlight Repaints and confirm it's really your painter.
  2. Is something animating nearby? Add a RepaintBoundary.
  3. Does shouldRepaint return true unconditionally? Compare real fields.
  4. Are you rebuilding to animate? Use super(repaint: animation).

When a painter doesn't repaint:

  1. Are you mutating a list or object in place? Create a new one, or use a repaint notifier.
  2. Is shouldRepaint comparing the right fields?

Related articles