How to use
- Copy the installation command and review the source package before installing it in your own agent environment.
- Check the source document for required tools, credentials and supported hosts.
- Describe your input and desired deliverable; review the output against the source material.
npx skills add https://github.com/zakariaf/flutter-skills --skill custom-canvas-and-gesturesBefore you start
- Installation does not include a model subscription, API allowance or permission to access your files.
- Repository stars and installation counts describe activity, not an independent quality or security test.
Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
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.
- `shouldRepaint` is one value compare — `old.scene != scene` — and nothing else. Because
Scenehas 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 from `size` — it callstransform.toLogical.
- Hit-test by geometry class — integers for a lattice, a rasterized ID buffer for irregular regions — never `Path.contains` in 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 every
Paint,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 `AnimationController` passed as the painter's `repaint:` `Listenable` — never `setState`/`notifyListeners` in a ticker. That repaints the painter directly without rebuilding the widget tree. Resolve the animation's duration through
design-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. Read
TapDownDetails.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 via
Isolate.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. Set
isComplex/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 `CustomPainter` over an image asset for repeated vector detail — it stays crisp at every DPR and theme. For many identical marks, draw them in one
Canvaspass 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



