Skip to content
Back to skills

Custom Canvas And Gestures

ASecurity

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-a...

  • 15 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 19, 2026
ai-agentsgonodeawstestingapiperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 9 files and shows the line behind each finding

Scanned September 19, 2026

npx -y skills add zakariaf/Flutter-Skills --skill custom-canvas-and-gestures --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Custom Canvas And Gestures?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Custom Canvas And Gestures
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-custom-canvas-and-gestures/badge)](https://www.skillsdirectory.com/skills/zakariaf-custom-canvas-and-gestures)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: custom-canvas-and-gestures
description: 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.
---

# 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-allocation `paint()`, 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` — measured `TextPainter` fitting, 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

1. **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 `Scene` value type holding *everything the painter needs and nothing more*, and hands it to `CustomPaint`. The `Painter extends CustomPainter` is dumb: no `Notifier`, no `BuildContext`, no `DateTime`, no domain rules — it draws the Scene and never decides state. A dumb painter is testable and re-skinnable.

2. **`shouldRepaint` is one value compare — `old.scene != scene` — and nothing else.** Because `Scene` has value equality, this is both correct and cheap. `=> true` repaints every frame; a wrong `false` freezes the surface — both fail silently. Config-driven repaints go through `shouldRepaint`; per-frame animation goes through the `repaint:` `Listenable`. The two paths must never double-repaint.

3. **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`; expose `toCanvas`/`toLogical` as 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 calls `transform.toLogical`.

4. **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 a `cell → RegionId` `Uint32List` rasterized **once at load**. O(1) and concavity-proof; testing a tap against N concave polygons every touch is not.

5. **Allocate nothing inside `paint()`.** Precompute every `Paint`, `Path`, and `Gradient` as painter fields and mutate `.color` per element rather than constructing. `Paint()`/`Path()` inside `paint()` is the classic jank source; reserve `saveLayer` for a real group opacity/blend, never a plain fill.

6. **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 / `resolveMotion` helper (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) per `accessibility-as-code`.

7. **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` (never `globalPosition`), map through `transform.toLogical`, and call `ref.read(vmProvider.notifier).<command>(...)`. Wrap the `CustomPaint` in `HitTestBehavior.opaque` so the whole rect is live over transparent gaps.

8. **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.

9. **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 in `paint()`.

10. **A custom-drawn surface is opaque to screen readers — author Semantics explicitly.** Either wrap the `CustomPaint` in `ExcludeSemantics` with a sibling `Semantics` node that speaks the **display value** (the answer, not the shape), or return `List<CustomPainterSemantics>` from `semanticsBuilder` for per-element nodes. Colour is never the only channel — the never-colour-alone rule is owned by `accessibility-as-code`. The a11y action commits the **same** command the gesture does.

11. **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 `*Directional` insets — never a hard-coded `Offset(-x, y)`, `.left`, or `.right`.

12. **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`/`willChange` honestly. 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.

```dart
@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.

```dart
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

```dart
// 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()

```dart
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

```dart
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

```dart
// 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.

```dart
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 `Canvas` pass rather than N widgets.
- **First-party squircle:** `RoundedSuperellipseBorder` / `ClipRSuperellipse` / `Canvas.drawRSuperellipse` are first-party with an Impeller GPU path. Do not reach for `figma_squircle`/`smooth_corner`; `ContinuousRectangleBorder` is not an iOS-grade squircle.
- **A true hairline is one physical pixel:** `1.0 / MediaQuery.devicePixelRatioOf(context)` with `strokeAlign: 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 a `shouldRepaint` that deep-walks mutable objects — the Scene is a value type; compare it (rule 2).
- A god widget that holds state, reads `ref` inside `paint()`, or decides a rule in the painter — the painter is dumb (rule 1).
- A hit-tester that re-derives `scale` from `size`, or math off `globalPosition` — always `transform.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 inside `paint()` (when hoistable), a per-frame `ui.Gradient`, or `saveLayer` for a plain fill (rule 5).
- Animating by `setState`/`notifyListeners` every ticker frame — pass the controller as `repaint:` 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 no `ExcludeSemantics` on the decorative painter (rule 10).
- A hard-coded `Offset(-x, y)`, `.left`/`.right`, or `Alignment.centerRight` in a painter — derive from `Directionality.of(context)` (rule 11).
- `FittedBox`/auto-shrink where uniform sizing is required; a constrained `TextPainter.layout()` in a fitter (measured-fit section).
- `Border.all()` for a hairline; `ContinuousRectangleBorder`/third-party squircle packages (shapes section).
- A `CustomPaint` with no `RepaintBoundary`, or blanket boundaries everywhere (rule 12).

## Definition of done

- [ ] The surface is a View + a `Painter extends CustomPainter` + an immutable `Scene`; the painter holds no `Notifier`/`BuildContext`/`DateTime`/rule (rule 1).
- [ ] `shouldRepaint` returns `old.scene != scene` only; per-frame animation flows through `repaint:`, not `shouldRepaint` (rules 2, 6).
- [ ] Exactly one transform is built per layout and read by both painter and hit-tester; `toLogical`/`toCanvas` are exact inverses; the hit-tester never re-derives scale (rule 3).
- [ ] Hit-testing uses integer lattice math or a `Uint32List` region-ID buffer rasterized once; no `Path.contains` in the hot path; no target below ~44 pt (rules 4, 7).
- [ ] `paint()` allocates nothing hoistable — `Paint`/`Path`/`Gradient` are fields, `.color` mutated per element, `saveLayer` only for a real group blend (rule 5).
- [ ] Animation is driven by an `AnimationController` as `repaint:`; motion is resolved through `design-system-structure`'s `resolveMotion` (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; the `CustomPaint` is under `HitTestBehavior.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 sibling `Semantics`/`semanticsBuilder` speaks 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`/`willChange` set honestly (rule 12).
- [ ] `scripts/check_painter_hygiene.sh` and `scripts/analyze.sh` pass.

## Related skills

- See `state-management-riverpod` for the `Notifier`/`AsyncNotifier` ViewModel the View watches and the commands gestures call.
- See `widget-composition` for the small-const-widget composition the View lives inside and controller disposal.
- See `flutter-performance` for `.select` rebuild scoping, `RepaintBoundary` budgeting, and off-isolate work.
- See `design-system-structure` for 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 reads `BuildContext`. It owns the `resolveMotion` reduced-motion helper this skill's animation path defers to.
- See `accessibility-as-code` for the never-colour-alone, MediaQuery-a11y-flag, redundant-channel, 44px-target, and `sortKey` rules the Semantics here obey.
- See `i18n-rtl-l10n` for the canonical-store + localize-at-render contract that feeds display values into `paint()`.
- See `motion-and-haptics` for 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-testing` for 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

Files in this skill

  • SKILL.md20.5 KB
  • examples/gesture_translator.dart4.4 KB
  • examples/scene_painter.dart7.8 KB
  • examples/text_fitter.dart4.4 KB
  • references/gestures-and-semantics.md5.5 KB
  • references/painter-and-scene.md7.1 KB
  • references/text-and-shapes.md5.3 KB
  • scripts/analyze.sh688 B
  • scripts/check_painter_hygiene.sh4.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.