Custom Canvas & Gestures
Hand-painted surfaces (CustomPainter/Canvas) own every pixel, so they look byte-identical across platforms — but only if the painter stays dumb, the coordinate math is shared, and gestures translate rather than decide. This skill is the contract for that layer: how it splits, how pixels and pointers agree, and how a screen reader hears a canvas.
Read the reference for the task at hand:
references/painter-and-scene.md— the View/Painter/Scene split, the shared transform, hit-testing by geometry class, zero-allocationpaint(), the two-path repaint pitfall.references/gestures-and-semantics.md— gesture-as-translator, arena arbitration, clamp-not-collision drags, custom-canvas Semantics, action/gesture parity.references/text-and-shapes.md— measuredTextPainterfitting, optical centring,RoundedSuperellipseBorder, physical-pixel hairlines, concentric radii, painter-over-image-asset.
Run scripts/check_painter_hygiene.sh and scripts/analyze.sh before a PR.
Non-negotiable rules
A canvas surface is three collaborators — a View, a Painter, and an immutable Scene — never one god-widget. The View watches the ViewModel, builds an immutable
Scenevalue type holding everything the painter needs and nothing more, and hands it toCustomPaint. ThePainter extends CustomPainteris dumb: noNotifier, noBuildContext, noDateTime, no domain rules — it draws the Scene and never decides state. A dumb painter is testable and re-skinnable.shouldRepaintis one value compare —old.scene != scene— and nothing else. BecauseScenehas value equality, this is both correct and cheap.=> truerepaints every frame; a wrongfalsefreezes the surface — both fail silently. Config-driven repaints go throughshouldRepaint; per-frame animation goes through therepaint:Listenable. The two paths must never double-repaint.Exactly one affine transform maps logical space ↔ canvas pixels, read by BOTH the painter and the hit-tester. Build it once per layout from the incoming
Size; exposetoCanvas/toLogicalas exact inverses. The single most corrosive bug is a painter and hit-tester that disagree by a few pixels; a shared, tested transform forecloses it. The hit-tester never re-derives scale fromsize— it callstransform.toLogical.Hit-test by geometry class — integers for a lattice, a rasterized ID buffer for irregular regions — never
Path.containsin the hot path. A grid cell is(x ~/ cell, y ~/ cell), one division per axis. An irregular region is resolved by indexing acell → RegionIdUint32Listrasterized once at load. O(1) and concavity-proof; testing a tap against N concave polygons every touch is not.Allocate nothing inside
paint(). Precompute everyPaint,Path, andGradientas painter fields and mutate.colorper element rather than constructing.Paint()/Path()insidepaint()is the classic jank source; reservesaveLayerfor a real group opacity/blend, never a plain fill.Every animation is driven by an
AnimationControllerpassed as the painter'srepaint:Listenable— neversetState/notifyListenersin a ticker. That repaints the painter directly without rebuilding the widget tree. Resolve the animation's duration throughdesign-system-structure's reduced-motion token /resolveMotionhelper (it owns that rule — collapse to zero, not gentler); when motion is suppressed, cut straight to the end state rather than tweening. A state change must never rely on motion or hue as its only signal — carry a redundant non-colour channel (shape + label + haptic) peraccessibility-as-code.A gesture handler is a pure translator: pointer →
localPosition→ shared transform → hit-test → typed immutable command → a ViewModel method. It mutates nothing. ReadTapDownDetails.localPosition/DragUpdateDetails.localPosition(neverglobalPosition), map throughtransform.toLogical, and callref.read(vmProvider.notifier).<command>(...). Wrap theCustomPaintinHitTestBehavior.opaqueso the whole rect is live over transparent gaps.One verb, one recognizer; arbitrate collisions explicitly in the arena. The primary verb owns a single recognizer; secondary verbs get separate recognizers. Where two can claim a pointer, resolve it via
RawGestureDetector— do not hope. An axis-locked drag is a clamp (bounds snapshot at drag-start), not per-frame collision detection.Canonical in, display out — never convert units, format dates, or shape numerals inside
paint(). The painter receives values already converted to display units and formatted upstream; it maps numbers to pixels. Bucket/downsample large histories off-isolate viaIsolate.run, keyed off a revision counter — never loop thousands of points inpaint().A custom-drawn surface is opaque to screen readers — author Semantics explicitly. Either wrap the
CustomPaintinExcludeSemanticswith a siblingSemanticsnode that speaks the display value (the answer, not the shape), or returnList<CustomPainterSemantics>fromsemanticsBuilderfor per-element nodes. Colour is never the only channel — the never-colour-alone rule is owned byaccessibility-as-code. The a11y action commits the same command the gesture does.Geometry is direction-agnostic; only chrome mirrors. Plotted data, a physical drag, a waveform have no handedness — never flip them for RTL. Legends, tooltips, axis placement, and label lead-edge mirror. Derive any sign from
Directionality.of(context)and use*Directionalinsets — never a hard-codedOffset(-x, y),.left, or.right.Isolate the surface behind a
RepaintBoundary. It gets its own compositor layer so a canvas tick does not repaint the surrounding chrome and vice versa. SetisComplex/willChangehonestly. Do not blanket-wrap everything — each boundary costs GPU memory.
View / Painter / Scene
The Scene is the painter's entire input, an immutable value type (@freezed or a hand-rolled @immutable with ==/hashCode). Value equality is what makes shouldRepaint a cheap compare.
@immutable
class ChartScene {
const ChartScene({required this.points, required this.transform, required this.phase});
final List<Offset> points; // logical space, already downsampled upstream
final CanvasTransform transform; // the ONE mapping (rule 3)
final double phase; // 0..1 animation value, or 0 when idle
@override
bool operator ==(Object other) =>
other is ChartScene &&
identical(other.points, points) && // Notifier hands a new list only on real change
other.transform == transform &&
other.phase == phase;
@override
int get hashCode => Object.hash(points.length, transform, phase);
}
The View watches the ViewModel (a Riverpod Notifier/AsyncNotifier), projects its state into the Scene, and never mutates state from inside the painter. See state-management-riverpod for the ViewModel spine.
The shared transform
One uniform scale + centering origin; toLogical is the exact inverse of toCanvas. Both the painter and the hit-tester read it.
class CanvasTransform {
const CanvasTransform({required this.scale, required this.origin});
final double scale; // logical unit -> px
final Offset origin; // top-left of the drawn rect within the canvas, in px
Offset toCanvas(Offset logical) => origin + logical * scale;
Offset toLogical(Offset canvasPx) => (canvasPx - origin) / scale; // exact inverse
factory CanvasTransform.fit(Size size, Size logicalBounds) {
final scale = math.min(size.width / logicalBounds.width,
size.height / logicalBounds.height);
final drawn = logicalBounds * scale;
return CanvasTransform(
scale: scale,
origin: Offset((size.width - drawn.width) / 2, (size.height - drawn.height) / 2),
);
}
}
Hit-testing by geometry class
// Lattice: pure integers, one division per axis.
({int col, int row})? hitLattice(Offset p, int cols, int rows) {
final c = p.dx.floor(), r = p.dy.floor();
if (c < 0 || r < 0 || c >= cols || r >= rows) return null;
return (col: c, row: r);
}
// Irregular regions: index a Uint32List rasterized ONCE at load — O(1), concavity-proof.
int? hitRegion(Offset p, RegionIdMap m) {
final gx = (p.dx * m.cols / m.logicalWidth).floor();
final gy = (p.dy * m.rows / m.logicalHeight).floor();
if (gx < 0 || gy < 0 || gx >= m.cols || gy >= m.rows) return null;
return m.ids[gy * m.cols + gx];
}
Build the buffer once by drawing each region in a unique id-colour into a PictureRecorder → Picture.toImage → Image.toByteData, read into a Uint32List, and cache it. Rebuild only on a genuine geometry change. Full recipe in references/painter-and-scene.md.
Zero-allocation paint()
class ChartPainter extends CustomPainter {
ChartPainter(this.scene, {required Listenable repaint}) : super(repaint: repaint);
final ChartScene scene;
// Paints are FIELDS — allocate nothing in paint() (rule 5).
final Paint _line = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
@override
void paint(Canvas canvas, Size size) {
if (scene.points.length < 2) return; // empty state handled by the View, not here
final t = scene.transform; // the SAME transform the hit-tester inverts (rule 3)
final first = t.toCanvas(scene.points.first);
final path = Path()..moveTo(first.dx, first.dy);
for (final p in scene.points.skip(1)) {
final c = t.toCanvas(p); // logical -> canvas px, mapped here in the painter
path.lineTo(c.dx, c.dy);
}
canvas.drawPath(path, _line);
}
@override
bool shouldRepaint(ChartPainter old) => old.scene != scene; // one value compare (rule 2)
}
Gesture → typed command
class ChartView extends ConsumerWidget {
const ChartView({required this.scene, super.key});
final ChartScene scene;
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(chartNotifierProvider.notifier);
return RepaintBoundary( // own compositor layer (rule 12)
child: GestureDetector(
behavior: HitTestBehavior.opaque, // whole rect is live (rule 7)
onTapUp: (d) {
final logical = scene.transform.toLogical(d.localPosition); // never globalPosition
final hit = hitLattice(logical, scene.cols, scene.rows);
if (hit != null) vm.select(hit.col, hit.row); // typed command; no mutation here
},
child: CustomPaint(
painter: ChartPainter(scene, repaint: ref.watch(chartTickProvider)),
isComplex: true,
willChange: scene.phase != 0,
),
),
);
}
}
Semantics over a canvas
// Simple case: the whole surface speaks one display value.
Semantics(
label: 'Balance trending up, from 12 to 41 over the last 30 days', // the answer, display values
child: ExcludeSemantics( // the painter itself says nothing
child: RepaintBoundary(child: CustomPaint(painter: ChartPainter(scene, repaint: tick))),
),
)
For per-element nodes (each tappable), return List<CustomPainterSemantics> from semanticsBuilder and override shouldRebuildSemantics — see references/gestures-and-semantics.md. The a11y onTap must call the same command the gesture does.
Measured text fitting
Scale text to a measured width with a linear TextPainter probe. Layout unconstrained — a constrained layout() wraps and tp.width then reports the constraint, silently returning the same size for every line.
double fitFontSize(String text, TextStyle style, double maxWidth,
{double min = 12, double max = 96}) {
const probe = 100.0;
final tp = TextPainter(
text: TextSpan(text: text, style: style.copyWith(fontSize: probe)),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout(); // NO maxWidth — glyph advances scale linearly with fontSize
return (probe * maxWidth / tp.width).clamp(min, max);
}
Set weight with fontWeight only — do not also pass FontVariation('wght', …); FontWeight drives the axis and passing both conflicts. Fix optical centring metrically with TextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even) — never a hardcoded pixel nudge, which breaks at 200% text scale. Never FittedBox/auto-shrink for content that must stay a uniform size. Details in references/text-and-shapes.md.
Shapes & hairlines
- Prefer a
CustomPainterover an image asset for repeated vector detail — it stays crisp at every DPR and theme. For many identical marks, draw them in oneCanvaspass rather than N widgets. - First-party squircle:
RoundedSuperellipseBorder/ClipRSuperellipse/Canvas.drawRSuperellipseare first-party with an Impeller GPU path. Do not reach forfigma_squircle/smooth_corner;ContinuousRectangleBorderis not an iOS-grade squircle. - A true hairline is one physical pixel:
1.0 / MediaQuery.devicePixelRatioOf(context)withstrokeAlign: BorderSide.strokeAlignInside.Border.all()defaults to 1.0 logical px = ~3 physical px on a modern phone — a table border, not a hairline. - Nested corners are concentric by construction:
inner = outer - padding, computed, never a second constant that drifts.
Anti-patterns
shouldRepaint(_) => true, or ashouldRepaintthat deep-walks mutable objects — the Scene is a value type; compare it (rule 2).- A god widget that holds state, reads
refinsidepaint(), or decides a rule in the painter — the painter is dumb (rule 1). - A hit-tester that re-derives
scalefromsize, or math offglobalPosition— alwaystransform.toLogical(localPosition)(rules 3, 7). Path.contains(tap)in the touch path, or re-rasterizing the ID buffer per tap/frame — it is geometry-stable (rule 4).Paint()/Path()allocated insidepaint()(when hoistable), a per-frameui.Gradient, orsaveLayerfor a plain fill (rule 5).- Animating by
setState/notifyListenersevery ticker frame — pass the controller asrepaint:instead (rule 6). - An un-skippable animation, or motion/hue as the only signal for a state change (rule 6).
- Converting units / formatting dates / shaping numerals inside
paint(), or looping raw history there — do it upstream, off-isolate (rule 9). - One opaque
Semantics(label: 'chart')that describes the shape instead of the value, or noExcludeSemanticson the decorative painter (rule 10). - A hard-coded
Offset(-x, y),.left/.right, orAlignment.centerRightin a painter — derive fromDirectionality.of(context)(rule 11). FittedBox/auto-shrink where uniform sizing is required; a constrainedTextPainter.layout()in a fitter (measured-fit section).Border.all()for a hairline;ContinuousRectangleBorder/third-party squircle packages (shapes section).- A
CustomPaintwith noRepaintBoundary, or blanket boundaries everywhere (rule 12).
Definition of done
- The surface is a View + a
Painter extends CustomPainter+ an immutableScene; the painter holds noNotifier/BuildContext/DateTime/rule (rule 1). -
shouldRepaintreturnsold.scene != sceneonly; per-frame animation flows throughrepaint:, notshouldRepaint(rules 2, 6). - Exactly one transform is built per layout and read by both painter and hit-tester;
toLogical/toCanvasare exact inverses; the hit-tester never re-derives scale (rule 3). - Hit-testing uses integer lattice math or a
Uint32Listregion-ID buffer rasterized once; noPath.containsin the hot path; no target below ~44 pt (rules 4, 7). -
paint()allocates nothing hoistable —Paint/Path/Gradientare fields,.colormutated per element,saveLayeronly for a real group blend (rule 5). - Animation is driven by an
AnimationControllerasrepaint:; motion is resolved throughdesign-system-structure'sresolveMotion(cut to end state when suppressed); no state change relies on hue or motion alone (rule 6). - Gesture handlers translate
localPosition→ transform → hit-test → typed command → ViewModel method and mutate nothing; theCustomPaintis underHitTestBehavior.opaque(rule 7). - One recognizer per verb; arena collisions resolved via
RawGestureDetector; axis-locked drags clamp to a drag-start bound (rule 8). -
paint()receives canonical→display values formatted upstream; large histories are downsampled off-isolate keyed on a revision (rule 9). - The painter is
ExcludeSemantics, and a siblingSemantics/semanticsBuilderspeaks display values with a redundant non-colour channel; the a11y action equals the gesture command (rule 10). - No hard-coded directional sign in any painter; geometry is direction-agnostic, chrome mirrors from
Directionality.of(context)(rule 11). - The surface sits under a
RepaintBoundary;isComplex/willChangeset honestly (rule 12). -
scripts/check_painter_hygiene.shandscripts/analyze.shpass.
Related skills
- See
state-management-riverpodfor theNotifier/AsyncNotifierViewModel the View watches and the commands gestures call. - See
widget-compositionfor the small-const-widget composition the View lives inside and controller disposal. - See
flutter-performancefor.selectrebuild scoping,RepaintBoundarybudgeting, and off-isolate work. - See
design-system-structurefor the theme colours, hairline/shape, and reduced-motion tokens the View snapshots at the widget layer and passes into painter/Scene fields — the painter never readsBuildContext. It owns theresolveMotionreduced-motion helper this skill's animation path defers to. - See
accessibility-as-codefor the never-colour-alone, MediaQuery-a11y-flag, redundant-channel, 44px-target, andsortKeyrules the Semantics here obey. - See
i18n-rtl-l10nfor the canonical-store + localize-at-render contract that feeds display values intopaint(). - See
motion-and-hapticsfor what animation on this surface commits to: one haptic per committed gesture, interruptibility, and the declared reduced-motion end state. - See
widget-golden-and-a11y-testingfor pinning the painted surface with a golden on real fonts.
References
- Flutter API —
CustomPainter(paint,shouldRepaint,semanticsBuilder,repaint): https://api.flutter.dev/flutter/rendering/CustomPainter-class.html - Flutter API —
CustomPaint(isComplex,willChange,foregroundPainter): https://api.flutter.dev/flutter/widgets/CustomPaint-class.html - Flutter API —
Canvas: https://api.flutter.dev/flutter/dart-ui/Canvas-class.html - Flutter API —
PictureRecorder/Picture.toImage(rasterize-once ID buffer): https://api.flutter.dev/flutter/dart-ui/PictureRecorder-class.html - Flutter — Taps, drags, and other gestures (arena,
localPosition): https://docs.flutter.dev/ui/interactivity/gestures - Flutter — Performance best practices (
RepaintBoundary,saveLayer): https://docs.flutter.dev/perf/best-practices - Flutter API —
RoundedSuperellipseBorder: https://api.flutter.dev/flutter/painting/RoundedSuperellipseBorder-class.html - Flutter API —
TextPainter: https://api.flutter.dev/flutter/painting/TextPainter-class.html - Flutter — Accessibility & Semantics (
CustomPainterSemantics): https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility - W3C — WCAG 2.2 §1.4.1 Use of Color: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html