Loading...
Loading...
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.
npx skill4agent add zakariaf/flutter-skills custom-canvas-and-gesturesCustomPainterCanvasreferences/painter-and-scene.mdpaint()references/gestures-and-semantics.mdreferences/text-and-shapes.mdTextPainterRoundedSuperellipseBorderscripts/check_painter_hygiene.shscripts/analyze.shSceneCustomPaintPainter extends CustomPainterNotifierBuildContextDateTimeshouldRepaintold.scene != sceneScene=> truefalseshouldRepaintrepaint:ListenableSizetoCanvastoLogicalsizetransform.toLogicalPath.contains(x ~/ cell, y ~/ cell)cell → RegionIdUint32Listpaint()PaintPathGradient.colorPaint()Path()paint()saveLayerAnimationControllerrepaint:ListenablesetStatenotifyListenersdesign-system-structureresolveMotionaccessibility-as-codelocalPositionTapDownDetails.localPositionDragUpdateDetails.localPositionglobalPositiontransform.toLogicalref.read(vmProvider.notifier).<command>(...)CustomPaintHitTestBehavior.opaqueRawGestureDetectorpaint()Isolate.runpaint()CustomPaintExcludeSemanticsSemanticsList<CustomPainterSemantics>semanticsBuilderaccessibility-as-codeDirectionality.of(context)*DirectionalOffset(-x, y).left.rightRepaintBoundaryisComplexwillChange@freezed@immutable==hashCodeshouldRepaint
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
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;
int get hashCode => Object.hash(points.length, transform, phase);
}NotifierAsyncNotifierstate-management-riverpodscaleorigintoLogicaltoCanvasclass 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),
);
}
}// 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];
}PictureRecorderPicture.toImageImage.toByteDataUint32Listreferences/painter-and-scene.mdclass 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;
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);
}
bool shouldRepaint(ChartPainter old) => old.scene != scene; // one value compare (rule 2)
}class ChartView extends ConsumerWidget {
const ChartView({required this.scene, super.key});
final ChartScene scene;
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,
),
),
);
}
}// 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))),
),
)List<CustomPainterSemantics>semanticsBuildershouldRebuildSemanticsreferences/gestures-and-semantics.mdonTapTextPainterlayout()tp.widthdouble 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);
}fontWeightFontVariation('wght', …)FontWeightTextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even)FittedBoxreferences/text-and-shapes.mdCustomPainterCanvasRoundedSuperellipseBorderClipRSuperellipseCanvas.drawRSuperellipsefigma_squirclesmooth_cornerContinuousRectangleBorder1.0 / MediaQuery.devicePixelRatioOf(context)strokeAlign: BorderSide.strokeAlignInsideBorder.all()inner = outer - paddingshouldRepaint(_) => trueshouldRepaintrefpaint()scalesizeglobalPositiontransform.toLogical(localPosition)Path.contains(tap)Paint()Path()paint()ui.GradientsaveLayersetStatenotifyListenersrepaint:paint()Semantics(label: 'chart')ExcludeSemanticsOffset(-x, y).left.rightAlignment.centerRightDirectionality.of(context)FittedBoxTextPainter.layout()Border.all()ContinuousRectangleBorderCustomPaintRepaintBoundaryPainter extends CustomPainterSceneNotifierBuildContextDateTimeshouldRepaintold.scene != scenerepaint:shouldRepainttoLogicaltoCanvasUint32ListPath.containspaint()PaintPathGradient.colorsaveLayerAnimationControllerrepaint:design-system-structureresolveMotionlocalPositionCustomPaintHitTestBehavior.opaqueRawGestureDetectorpaint()ExcludeSemanticsSemanticssemanticsBuilderDirectionality.of(context)RepaintBoundaryisComplexwillChangescripts/check_painter_hygiene.shscripts/analyze.shstate-management-riverpodNotifierAsyncNotifierwidget-compositionflutter-performance.selectRepaintBoundarydesign-system-structureBuildContextresolveMotionaccessibility-as-codesortKeyi18n-rtl-l10npaint()motion-and-hapticswidget-golden-and-a11y-testingCustomPainterpaintshouldRepaintsemanticsBuilderrepaintCustomPaintisComplexwillChangeforegroundPainterCanvasPictureRecorderPicture.toImagelocalPositionRepaintBoundarysaveLayerRoundedSuperellipseBorderTextPainterCustomPainterSemantics