Custom canvas and gestures
Skill zakariaf/Flutter-Skills/skills/custom-canvas-and-gestures
General, reusable Flutter engineering skills for AI coding agents — architecture, Riverpod 3.x, testing, persistence, i18n/RTL, accessibility, navigation & more. Agent Skills open standard; works with Claude Code, Cursor, Codex & 70+ agents.
npx -y skills add zakariaf/Flutter-Skills --skill custom-canvas-and-gesturesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 15 days oldThe repository was created 15 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Enforces CustomPainter/Canvas discipline — the View/Painter/Scene split with a dumb painter fed one immutable Scene value type, shouldRepaint as a single value compare kept strictly separate from the AnimationController-as-repaint animation path, one shared affine transform read by BOTH painter and hit-tester (toCanvas/toLogical exact inverses, never re-derive scale), geometry hit-testing (integer lattice or rasterized region-ID buffer, never Path.contains), zero-allocation paint(), gesture-as-pure-translator emitting a typed command to a Notifier (never mutating in the handler), ExcludeSemantics + sibling Semantics speaking display values with redundant non-colour encoding, measured TextPainter fitting, first-party RoundedSuperellipseBorder, physical-pixel hairlines, and Directional-only geometry. Use when writing or reviewing a CustomPainter/CustomPaint, gestures on a canvas, tap/drag hit-testing, canvas animation, measured text fitting, or Semantics over custom-drawn pixels.
SKILL.md
20.4 KB, as published. Nobody here has run it
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
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