custom-canvas-and-gestures
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.