skip to content

In a Flutter voice-memo app, how do you repaint a live audio waveform on every new amplitude sample without rebuilding the widget tree?

level: middleimportance: must knowfreq 45%

answer

  1. CustomPainter(repaint: listenable)
  2. notify, then markNeedsPaint
  3. skips build and layout
  4. ChangeNotifier owned by the State
  5. ValueNotifier ignores same instance

basics

~10 s

Keep the samples in a ChangeNotifier owned by the State and pass it to the painter's super(repaint: ...). Each notifyListeners makes RenderCustomPaint call markNeedsPaint, so only paint runs: no setState, no build, no layout.

solid answer

~40 s

Hold the amplitude buffer in a `ChangeNotifier` (for example a small `WaveformBuffer` class) created and disposed by the recording screen's `State`. The painter takes it in its constructor and forwards it with `super(repaint: buffer)`; `CustomPainter` then acts as that listenable, and `RenderCustomPaint` subscribes `markNeedsPaint` to it. When a new sample arrives, add it to the buffer and call `notifyListeners()`: the next frame repaints the waveform **without** rebuilding any widget or relaying out anything, which the framework docs call the most efficient way to trigger a repaint. `paint` reads the buffer's current samples. Two traps: a `ValueNotifier<List<double>>` does not notify if you mutate the list and assign the same instance, and `shouldRepaint` only needs to compare configuration such as colours, because sample changes arrive through `repaint`.

code

dart · 39 lines
dart
class WaveformBuffer extends ChangeNotifier {
  WaveformBuffer(this.capacity);
  final int capacity;
  final List<double> _samples = <double>[];
  List<double> get samples => _samples;

  void add(double amplitude) {
    _samples.add(amplitude.clamp(0.0, 1.0).toDouble());
    if (_samples.length > capacity) _samples.removeAt(0);
    notifyListeners();
  }
}

class WaveformPainter extends CustomPainter {
  WaveformPainter(this.buffer, this.color) : super(repaint: buffer);
  final WaveformBuffer buffer;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final samples = buffer.samples;
    if (samples.isEmpty) return;
    final bar = Paint()
      ..color = color
      ..strokeWidth = 2
      ..strokeCap = StrokeCap.round;
    final step = size.width / buffer.capacity;
    final mid = size.height / 2;
    for (var i = 0; i < samples.length; i++) {
      final x = i * step;
      final h = samples[i] * mid;
      canvas.drawLine(Offset(x, mid - h), Offset(x, mid + h), bar);
    }
  }

  @override
  bool shouldRepaint(WaveformPainter oldDelegate) =>
      oldDelegate.buffer != buffer || oldDelegate.color != color;
}

go deeper

for a junior

Remember that CustomPainter takes a repaint listenable, and that notifying it redraws the painter without setState.

for a middle

Explain how repaint forwards to markNeedsPaint, why build and layout are skipped, and the ValueNotifier same-instance trap.

for a senior

Own the notifier's lifecycle in a State, bound the sample buffer, merge multiple sources, and keep the frequently repainting region isolated from static content.

for a principal

Set a pattern for high-frequency visual data across the app, such as notifier-driven painters, so real-time features stay smooth without ad hoc setState loops.

## The requirement A voice-memo screen shows a live waveform while recording. The audio plugin delivers an amplitude reading dozens of times per second. The waveform must scroll smoothly, while the rest of the screen (the title, the timer, the stop button) stays still. The naive version calls `setState` on every sample and builds a new painter with a new list. It works, but each sample rebuilds the screen's widgets, re-runs `shouldRepaint`, and if the list is rebuilt every time, repaints anyway. On a busy screen that is avoidable work many times a second. ## The mechanism: the repaint listenable `CustomPainter` itself extends `Listenable`. Its constructor takes an optional `repaint` argument: ```dart const CustomPainter({Listenable? repaint}) ``` `CustomPainter.addListener` and `removeListener` forward to that object. When `RenderCustomPaint` attaches, it registers its own `markNeedsPaint` as a listener. So every time the listenable notifies: 1. `markNeedsPaint` marks the render object dirty for painting and requests a frame; 2. in that frame's paint phase, `paint(canvas, size)` runs with the latest data; 3. **build and layout are skipped**; no widget is rebuilt and no size is recomputed. The `CustomPainter` docs describe this as the most efficient way to trigger a repaint, and name two variants: pass a `repaint` listenable to the constructor, or make the painter itself a `Listenable` (for example with `ChangeNotifier`) and implement `CustomPainter`. ## A clean design - **`WaveformBuffer extends ChangeNotifier`**: holds a fixed-capacity list of recent samples, exposes `add(double)`, and calls `notifyListeners()` after each add. - **The recording `State`**: creates the buffer in `initState`, feeds it from the plugin's stream subscription, cancels the subscription and disposes the buffer in `dispose`. - **`WaveformPainter`**: takes the buffer and a colour, passes `repaint: buffer` to `super`, and in `paint` draws one bar or one polyline point per sample, scaled to `size`. - **The widget**: `CustomPaint(painter: WaveformPainter(buffer, color), size: ...)`, built once. `shouldRepaint` then only needs to compare what can change through rebuilds, such as the colour after a theme switch, and the buffer identity. ## Traps | Trap | Why it breaks | Fix | |---|---|---| | `ValueNotifier<List<double>>` with in-place `add` then `value = list` | the setter returns early when the new value equals the old one, and it is the same instance | use a `ChangeNotifier` and call `notifyListeners()` yourself, or assign a new list | | creating the buffer inside `build` | a fresh listenable every rebuild; old listeners leak and state resets | create it in `initState`, dispose it in `dispose` | | calling `setState` as well | brings back the rebuilds you removed | let `repaint` alone drive the painter | | never disposing the buffer or the stream subscription | callbacks keep firing after the screen closes | cancel and dispose in `dispose` | | unbounded sample list | `paint` gets slower as recording goes on | keep a ring buffer sized to the visible width | ## Why not StreamBuilder or AnimatedBuilder? Both would work. A `StreamBuilder` listening to the amplitude stream, or an `AnimatedBuilder` listening to the buffer, rebuilds its builder subtree on each event and creates a new painter each time, which then goes through `shouldRepaint`. That is a small rebuild, and often acceptable. The `repaint` argument goes one step further: there is no builder, no new painter and no `shouldRepaint` call, just `markNeedsPaint` on the existing render object. For data that changes many times a second and only affects pixels, it is the leanest option, and the waveform's `CustomPaint` is built once. ## Combining several sources If the painter depends on more than one changing source, for example the samples and a playback cursor, combine them with `Listenable.merge([buffer, cursor])` and pass the result as `repaint`. ## Keeping the repaint small The waveform repaints often while the rest of the screen does not. Isolating it in its own layer so the static parts are not re-recorded is a paint-phase concern covered with repaint boundaries. How often frames can run, and animation controllers as listenables, are covered in their own topics. ## What the interviewer is checking - You know `setState` is not the only way to update a drawing. - You can name `repaint:` and explain that it goes straight to `markNeedsPaint`. - You own the listenable's lifecycle in a `State`. - You avoid the `ValueNotifier` same-instance trap.

  • Why does a ValueNotifier<List<double>> sometimes fail to repaint the waveform?
    `ValueNotifier.value`'s setter returns without notifying when the new value equals the old one. If you add to the existing list and assign that same list back, it is equal to itself, so listeners, including the painter, are never told. Use a `ChangeNotifier` that calls `notifyListeners()` explicitly, or assign a new list each time.
  • The waveform also needs a playback cursor driven by a separate notifier. How do you wire both?
    Pass `Listenable.merge([buffer, cursor])` as the painter's `repaint` argument. The merged listenable notifies when either source does, so `paint` runs whenever the samples or the cursor change, still without any rebuild.
  • Who should create and dispose the WaveformBuffer, and why not the painter?
    The recording screen's `State`: create it in `initState`, dispose it and cancel the audio subscription in `dispose`. Painters are recreated on rebuilds and have no lifecycle hooks, so a listenable created inside one would be recreated, lose its samples and never be disposed.

saying these in an interview costs you the question

  • Calling setState for every sample is the only way to update a CustomPainter.
  • The repaint listenable triggers a rebuild of the CustomPaint widget.
  • Mutating a ValueNotifier's list and reassigning it always notifies listeners.
  • Creating the notifier inside build is fine because the painter owns it.
  • shouldRepaint must return true for the repaint listenable to work.