custom-canvas-and-gestures
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCustom Canvas & Gestures
自定义 Canvas 与手势
Hand-painted surfaces (/) 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.
CustomPainterCanvasRead the reference for the task at hand:
- — the View/Painter/Scene split, the shared transform, hit-testing by geometry class, zero-allocation
references/painter-and-scene.md, the two-path repaint pitfall.paint() - — gesture-as-translator, arena arbitration, clamp-not-collision drags, custom-canvas Semantics, action/gesture parity.
references/gestures-and-semantics.md - — measured
references/text-and-shapes.mdfitting, optical centring,TextPainter, physical-pixel hairlines, concentric radii, painter-over-image-asset.RoundedSuperellipseBorder
Run and before a PR.
scripts/check_painter_hygiene.shscripts/analyze.sh由手绘表面(/)掌控每一个像素,因此只有当 painter 保持 dumb、坐标计算共享,且手势只负责转换而不负责决策时,它们才能在不同平台上呈现逐字节一致的效果。本 skill 是该层的契约:如何拆分各部分、如何让像素与指针保持一致,以及如何让屏幕阅读器读懂 canvas。
CustomPainterCanvas阅读与当前任务对应的参考资料:
- ——View/Painter/Scene 拆分、共享 transform、按几何类型进行 hit-test、零分配的
references/painter-and-scene.md,以及双路径 repaint 陷阱。paint() - ——gesture-as-translator、arena 仲裁、采用 clamp 而非碰撞检测的拖拽、自定义 canvas 的 Semantics,以及操作与手势的一致性。
references/gestures-and-semantics.md - ——经测量的
references/text-and-shapes.md文本适配、光学居中、TextPainter、物理像素级 hairline、同心圆角,以及 painter 优于图片资源的场景。RoundedSuperellipseBorder
提交 PR 前运行 和 。
scripts/check_painter_hygiene.shscripts/analyze.shNon-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 immutablevalue type holding everything the painter needs and nothing more, and hands it to
Scene. TheCustomPaintis dumb: noPainter extends CustomPainter, noNotifier, noBuildContext, no domain rules — it draws the Scene and never decides state. A dumb painter is testable and re-skinnable.DateTime -
is one value compare —
shouldRepaint— and nothing else. Becauseold.scene != scenehas value equality, this is both correct and cheap.Scenerepaints every frame; a wrong=> truefreezes the surface — both fail silently. Config-driven repaints go throughfalse; per-frame animation goes through theshouldRepaintrepaint:. The two paths must never double-repaint.Listenable -
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; expose
Size/toCanvasas 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 fromtoLogical— it callssize.transform.toLogical -
Hit-test by geometry class — integers for a lattice, a rasterized ID buffer for irregular regions — neverin the hot path. A grid cell is
Path.contains, one division per axis. An irregular region is resolved by indexing a(x ~/ cell, y ~/ cell)cell → RegionIdrasterized once at load. O(1) and concavity-proof; testing a tap against N concave polygons every touch is not.Uint32List -
Allocate nothing inside. Precompute every
paint(),Paint, andPathas painter fields and mutateGradientper element rather than constructing..color/Paint()insidePath()is the classic jank source; reservepaint()for a real group opacity/blend, never a plain fill.saveLayer -
Every animation is driven by anpassed as the painter's
AnimationControllerrepaint:— neverListenable/setStatein a ticker. That repaints the painter directly without rebuilding the widget tree. Resolve the animation's duration throughnotifyListeners's reduced-motion token /design-system-structurehelper (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) perresolveMotion.accessibility-as-code -
A gesture handler is a pure translator: pointer →→ shared transform → hit-test → typed immutable command → a ViewModel method. It mutates nothing. Read
localPosition/TapDownDetails.localPosition(neverDragUpdateDetails.localPosition), map throughglobalPosition, and calltransform.toLogical. Wrap theref.read(vmProvider.notifier).<command>(...)inCustomPaintso the whole rect is live over transparent gaps.HitTestBehavior.opaque -
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— do not hope. An axis-locked drag is a clamp (bounds snapshot at drag-start), not per-frame collision detection.
RawGestureDetector -
Canonical in, display out — never convert units, format dates, or shape numerals inside. The painter receives values already converted to display units and formatted upstream; it maps numbers to pixels. Bucket/downsample large histories off-isolate via
paint(), keyed off a revision counter — never loop thousands of points inIsolate.run.paint() -
A custom-drawn surface is opaque to screen readers — author Semantics explicitly. Either wrap thein
CustomPaintwith a siblingExcludeSemanticsnode that speaks the display value (the answer, not the shape), or returnSemanticsfromList<CustomPainterSemantics>for per-element nodes. Colour is never the only channel — the never-colour-alone rule is owned bysemanticsBuilder. The a11y action commits the same command the gesture does.accessibility-as-code -
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 fromand use
Directionality.of(context)insets — never a hard-coded*Directional,Offset(-x, y), or.left..right -
Isolate the surface behind a. It gets its own compositor layer so a canvas tick does not repaint the surrounding chrome and vice versa. Set
RepaintBoundary/isComplexhonestly. Do not blanket-wrap everything — each boundary costs GPU memory.willChange
-
一个 canvas 表面由三个协作者组成——一个 View、一个 Painter 和一个不可变 Scene——绝不能是一个巨型 widget。 View 监听 ViewModel,构建一个不可变的值类型,其中包含 painter 所需的一切且不多余,并将其交给
Scene。CustomPaint保持 dumb:不含Painter extends CustomPainter、不读取Notifier、不使用BuildContext、不包含领域规则——它只绘制 Scene,绝不决定状态。dumb painter 可测试,也可轻松重新换肤。DateTime -
只做一次值比较——
shouldRepaint——不做其他任何事。 由于old.scene != scene具有值相等性,这种方式既正确又开销低。Scene会每帧重绘;错误的=> true会冻结表面——两者都会静默失败。配置驱动的重绘通过false完成;逐帧动画则通过shouldRepaintrepaint:完成。这两条路径绝不能导致双重重绘。Listenable -
只有一个仿射 transform 负责映射逻辑空间 ↔ canvas 像素,并且绘制器与命中测试器都要读取它。 每次布局时,根据传入的构建一次;将
Size/toCanvas暴露为互为精确逆运算的方法。绘制器与命中测试器相差几个像素,是最具破坏性的 bug;共享且经过测试的 transform 可以从根本上杜绝这种问题。命中测试器绝不从toLogical重新推导 scale——它应调用size。transform.toLogical -
按几何类型进行 hit-test——网格使用整数,不规则区域使用栅格化的 ID 缓冲区——热路径中绝不使用。 网格单元为
Path.contains,每个坐标轴只需一次除法。不规则区域通过索引在加载时仅栅格化一次的(x ~/ cell, y ~/ cell)cell → RegionId进行解析。其复杂度为 O(1),且不受区域凹凸影响;不应在每次触摸时都对 N 个凹多边形执行测试。Uint32List -
不要在内部分配任何对象。 将每个
paint()、Paint和Path预先计算为 painter 字段,并按元素修改Gradient,而不是重新构造。在.color内调用paint()/Paint()是卡顿的经典来源;仅在确实需要组透明度或混合效果时使用Path(),普通填充绝不使用它。saveLayer -
所有动画都由作为 painter 的
repaint:传入的Listenable驱动——绝不在 ticker 中调用AnimationController/setState。 这会直接重绘 painter,而无需重建 widget 树。通过notifyListeners的 reduced-motion token/design-system-structurehelper 解析动画时长(该 helper 负责此规则——直接折叠为零,而非仅仅减弱);动态效果被抑制时,应直接切换到最终状态,而不是执行 tween。状态变化绝不能仅依靠动态效果或色相作为信号——应按照resolveMotion的要求,提供冗余的非颜色通道,例如形状、标签和触觉反馈。accessibility-as-code -
手势处理器是纯转换器:pointer →→ 共享 transform → hit-test → 类型化的不可变命令 → ViewModel 方法。它不修改任何状态。 读取
localPosition/TapDownDetails.localPosition(绝不使用DragUpdateDetails.localPosition),通过globalPosition映射坐标,然后调用transform.toLogical。将ref.read(vmProvider.notifier).<command>(...)包裹在CustomPaint中,使整个矩形在透明空隙上方也处于可交互状态。HitTestBehavior.opaque -
一个手势动词对应一个 recognizer;在 arena 中显式仲裁冲突。 主要手势动词独占一个 recognizer;次要手势动词使用各自的 recognizer。当两个 recognizer 都可能认领同一 pointer 时,通过解决——不要依赖默认行为。轴锁定拖拽采用 clamp(边界在拖拽开始时快照固定),而不是逐帧碰撞检测。
RawGestureDetector -
规范值输入,显示值输出——绝不在内换算单位、格式化日期或处理数字字形。 Painter 接收上游已经转换为显示单位并完成格式化的值;它只负责将数字映射为像素。通过
paint()在后台 isolate 中,以 revision counter 为依据,对大型历史数据进行分桶或降采样——绝不在Isolate.run中循环处理数千个点。paint() -
自绘表面对于屏幕阅读器是 opaque 的——必须显式编写 Semantics。 要么将包裹在
CustomPaint中,并使用同级ExcludeSemantics节点朗读显示值(答案,而不是形状);要么从Semantics返回semanticsBuilder,为每个元素创建节点。颜色绝不能是唯一通道——“不可仅依赖颜色”的规则由List<CustomPainterSemantics>负责。无障碍操作必须执行与手势相同的命令。accessibility-as-code -
几何图形与方向无关;只有外围界面才会镜像。 绘制的数据、实际拖拽和波形都没有左右手性——绝不能为 RTL 进行翻转。图例、工具提示、坐标轴位置和标签前缘则应镜像。任何方向符号都应从推导,并使用
Directionality.of(context)insets——绝不要硬编码*Directional、Offset(-x, y)或.left。.right -
将表面隔离在后面。 它会获得独立的合成器层,因此 canvas 的一帧更新不会重绘周围界面,反之亦然。应如实设置
RepaintBoundary/isComplex。不要无差别地包裹所有内容——每个边界都会消耗 GPU 内存。willChange
View / Painter / Scene
View / Painter / Scene
The Scene is the painter's entire input, an immutable value type ( or a hand-rolled with /). Value equality is what makes a cheap compare.
@freezed@immutable==hashCodeshouldRepaintdart
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);
}The View watches the ViewModel (a Riverpod /), projects its state into the Scene, and never mutates state from inside the painter. See for the ViewModel spine.
NotifierAsyncNotifierstate-management-riverpodScene 是 painter 的完整输入,是一个不可变值类型(,或手写 并实现 /)。值相等性使 可以通过一次 inexpensive 比较完成。
@freezed@immutable==hashCodeshouldRepaintdart
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);
}View 监听 ViewModel(一个 Riverpod /),将其状态投射为 Scene,并且绝不在 painter 内部修改状态有关 ViewModel 架构,请参阅 。
NotifierAsyncNotifierstate-management-riverpodThe shared transform
共享 transform
One uniform + centering ; is the exact inverse of . Both the painter and the hit-tester read it.
scaleorigintoLogicaltoCanvasdart
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),
);
}
}使用单一统一的 和用于居中的 ; 是 的精确逆运算。Painter 与 hit-tester 均读取它。
scaleorigintoLogicaltoCanvasdart
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
按几何类型进行 hit-test
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 → → , read into a , and cache it. Rebuild only on a genuine geometry change. Full recipe in .
PictureRecorderPicture.toImageImage.toByteDataUint32Listreferences/painter-and-scene.mddart
// 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];
}通过将每个区域以唯一 id-colour 绘制到 → → 中,一次性构建缓冲区,将其读取为 并缓存。仅在几何结构实际发生变化时重建。完整方案请参阅 。
PictureRecorderPicture.toImageImage.toByteDataUint32Listreferences/painter-and-scene.mdZero-allocation paint()
零分配的 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;
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)
}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;
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)
}Gesture → typed command
手势 → 类型化命令
dart
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,
),
),
);
}
}dart
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,
),
),
);
}
}Semantics over a canvas
Canvas 上的 Semantics
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 from and override — see . The a11y must call the same command the gesture does.
List<CustomPainterSemantics>semanticsBuildershouldRebuildSemanticsreferences/gestures-and-semantics.mdonTapdart
// 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))),
),
)对于按元素划分的节点(每个节点都可点击),从 返回 ,并重写 ——请参阅 。无障碍 必须调用与手势相同的命令。
semanticsBuilderList<CustomPainterSemantics>shouldRebuildSemanticsreferences/gestures-and-semantics.mdonTapMeasured text fitting
经测量的文本适配
Scale text to a measured width with a linear probe. Layout unconstrained — a constrained wraps and then reports the constraint, silently returning the same size for every line.
TextPainterlayout()tp.widthdart
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 only — do not also pass ; drives the axis and passing both conflicts. Fix optical centring metrically with — never a hardcoded pixel nudge, which breaks at 200% text scale. Never /auto-shrink for content that must stay a uniform size. Details in .
fontWeightFontVariation('wght', …)FontWeightTextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even)FittedBoxreferences/text-and-shapes.md使用线性的 探针将文本缩放到实测宽度。布局时不要施加约束——受约束的 会换行,而 随后只会报告约束值,导致每一行都静默返回相同尺寸。
TextPainterlayout()tp.widthdart
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);
}仅通过 设置字重——不要同时传入 ; 会驱动该轴,同时传入两者会产生冲突。使用 以度量方式修正光学居中——绝不要使用硬编码的像素微调,否则在 200% 文本缩放下会失效。对于必须保持统一尺寸的内容,绝不要使用 /自动缩小。详情请参阅 。
fontWeightFontVariation('wght', …)FontWeightTextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even)FittedBoxreferences/text-and-shapes.mdShapes & hairlines
形状与 hairline
- Prefer a over an image asset for repeated vector detail — it stays crisp at every DPR and theme. For many identical marks, draw them in one
CustomPainterpass rather than N widgets.Canvas - First-party squircle: /
RoundedSuperellipseBorder/ClipRSuperellipseare first-party with an Impeller GPU path. Do not reach forCanvas.drawRSuperellipse/figma_squircle;smooth_corneris not an iOS-grade squircle.ContinuousRectangleBorder - A true hairline is one physical pixel: with
1.0 / MediaQuery.devicePixelRatioOf(context).strokeAlign: BorderSide.strokeAlignInsidedefaults to 1.0 logical px = ~3 physical px on a modern phone — a table border, not a hairline.Border.all() - Nested corners are concentric by construction: , computed, never a second constant that drifts.
inner = outer - padding
- 对于重复出现的矢量细节,优先使用 而非图片资源——它在所有 DPR 和主题下都能保持清晰。对于大量相同标记,应在一次
CustomPainterpass 中完成绘制,而不是创建 N 个 widget。Canvas - Flutter 官方 squircle:/
RoundedSuperellipseBorder/ClipRSuperellipse均为官方实现,并支持 Impeller GPU 路径。不要使用Canvas.drawRSuperellipse/figma_squircle;smooth_corner并非 iOS 级别的 squircle。ContinuousRectangleBorder - 真正的 hairline 是一个物理像素:使用 ,并设置
1.0 / MediaQuery.devicePixelRatioOf(context)。strokeAlign: BorderSide.strokeAlignInside默认使用 1.0 逻辑像素,在现代手机上约为 3 个物理像素——那是表格边框,而不是 hairline。Border.all() - 嵌套圆角在构造时天然保持同心:,应通过计算得出,绝不能使用第二个会逐渐偏离的常量。
inner = outer - padding
Anti-patterns
反模式
- , or a
shouldRepaint(_) => truethat deep-walks mutable objects — the Scene is a value type; compare it (rule 2).shouldRepaint - A god widget that holds state, reads inside
ref, or decides a rule in the painter — the painter is dumb (rule 1).paint() - A hit-tester that re-derives from
scale, or math offsize— alwaysglobalPosition(rules 3, 7).transform.toLogical(localPosition) - in the touch path, or re-rasterizing the ID buffer per tap/frame — it is geometry-stable (rule 4).
Path.contains(tap) - /
Paint()allocated insidePath()(when hoistable), a per-framepaint(), orui.Gradientfor a plain fill (rule 5).saveLayer - Animating by /
setStateevery ticker frame — pass the controller asnotifyListenersinstead (rule 6).repaint: - An un-skippable animation, or motion/hue as the only signal for a state change (rule 6).
- Converting units / formatting dates / shaping numerals inside , or looping raw history there — do it upstream, off-isolate (rule 9).
paint() - One opaque that describes the shape instead of the value, or no
Semantics(label: 'chart')on the decorative painter (rule 10).ExcludeSemantics - A hard-coded ,
Offset(-x, y)/.left, or.rightin a painter — derive fromAlignment.centerRight(rule 11).Directionality.of(context) - /auto-shrink where uniform sizing is required; a constrained
FittedBoxin a fitter (measured-fit section).TextPainter.layout() - for a hairline;
Border.all()/third-party squircle packages (shapes section).ContinuousRectangleBorder - A with no
CustomPaint, or blanket boundaries everywhere (rule 12).RepaintBoundary
- ,或者
shouldRepaint(_) => true深度遍历可变对象——Scene 是值类型;直接比较它(规则 2)。shouldRepaint - 巨型 widget 持有状态、在 内读取
paint(),或者由 painter 决定规则——painter 必须保持 dumb(规则 1)。ref - 命中测试器从 重新推导
size,或者基于scale进行计算——始终使用globalPosition(规则 3、7)。transform.toLogical(localPosition) - 在触摸路径中使用 ,或者每次点击/每帧都重新栅格化 ID 缓冲区——该缓冲区在几何结构稳定时无需重建(规则 4)。
Path.contains(tap) - 在 内分配可提升的
paint()/Paint()、逐帧创建Path(),或者为普通填充使用ui.Gradient(规则 5)。saveLayer - 每个 ticker 帧都通过 /
setState驱动动画——应改为传入 controller 作为notifyListeners(规则 6)。repaint: - 动画无法跳过,或者仅以动态效果/色相作为状态变化的唯一信号(规则 6)。
- 在 内换算单位、格式化日期、处理数字字形或循环处理原始历史数据——这些工作应在上游、后台 isolate 中完成(规则 9)。
paint() - 使用一个不透明的 描述形状而不是数值,或者未对装饰性 painter 使用
Semantics(label: 'chart')(规则 10)。ExcludeSemantics - 在 painter 中硬编码 、
Offset(-x, y)/.left或.right——应从Alignment.centerRight推导(规则 11)。Directionality.of(context) - 在要求统一尺寸的场景中使用 /自动缩小;在文本适配器中使用受约束的
FittedBox(经测量的文本适配部分)。TextPainter.layout() - 使用 绘制 hairline;使用
Border.all()/第三方 squircle 包(形状部分)。ContinuousRectangleBorder - 没有
CustomPaint,或者到处无差别添加边界(规则 12)。RepaintBoundary
Definition of done
完成标准
- The surface is a View + a + an immutable
Painter extends CustomPainter; the painter holds noScene/Notifier/BuildContext/rule (rule 1).DateTime - returns
shouldRepaintonly; per-frame animation flows throughold.scene != scene, notrepaint:(rules 2, 6).shouldRepaint - Exactly one transform is built per layout and read by both painter and hit-tester; /
toLogicalare exact inverses; the hit-tester never re-derives scale (rule 3).toCanvas - Hit-testing uses integer lattice math or a region-ID buffer rasterized once; no
Uint32Listin the hot path; no target below ~44 pt (rules 4, 7).Path.contains - allocates nothing hoistable —
paint()/Paint/Pathare fields,Gradientmutated per element,.coloronly for a real group blend (rule 5).saveLayer - Animation is driven by an as
AnimationController; motion is resolved throughrepaint:'sdesign-system-structure(cut to end state when suppressed); no state change relies on hue or motion alone (rule 6).resolveMotion - Gesture handlers translate → transform → hit-test → typed command → ViewModel method and mutate nothing; the
localPositionis underCustomPaint(rule 7).HitTestBehavior.opaque - One recognizer per verb; arena collisions resolved via ; axis-locked drags clamp to a drag-start bound (rule 8).
RawGestureDetector - receives canonical→display values formatted upstream; large histories are downsampled off-isolate keyed on a revision (rule 9).
paint() - The painter is , and a sibling
ExcludeSemantics/Semanticsspeaks display values with a redundant non-colour channel; the a11y action equals the gesture command (rule 10).semanticsBuilder - No hard-coded directional sign in any painter; geometry is direction-agnostic, chrome mirrors from (rule 11).
Directionality.of(context) - The surface sits under a ;
RepaintBoundary/isComplexset honestly (rule 12).willChange - and
scripts/check_painter_hygiene.shpass.scripts/analyze.sh
- 表面由 View + + 不可变 Scene 组成;painter 不持有
Painter extends CustomPainter/Notifier/BuildContext/业务规则(规则 1)。DateTime - 仅返回
shouldRepaint;逐帧动画通过old.scene != scene流动,而不是通过repaint:(规则 2、6)。shouldRepaint - 每次布局只构建一个 transform,并由 painter 与 hit-tester 共同读取;/
toLogical互为精确逆运算;hit-tester 绝不重新推导 scale(规则 3)。toCanvas - Hit-test 使用整数网格计算,或使用仅栅格化一次的 region-ID 缓冲区;热路径中不使用
Uint32List;目标尺寸不低于约 44 pt(规则 4、7)。Path.contains - 不分配任何可提升的对象——
paint()/Paint/Path均作为字段,按元素修改Gradient,仅在真正需要组混合时使用.color(规则 5)。saveLayer - 动画由 作为
AnimationController驱动;动态效果通过repaint:的design-system-structure解析(被抑制时直接切换到最终状态);状态变化不依赖色相或动态效果(规则 6)。resolveMotion - 手势处理器将 → transform → hit-test → 类型化命令 → ViewModel 方法,并且不修改任何状态;
localPosition位于CustomPaint之下(规则 7)。HitTestBehavior.opaque - 每个手势动词对应一个 recognizer;arena 冲突通过 解决;轴锁定拖拽会 clamp 到拖拽开始时的边界(规则 8)。
RawGestureDetector - 接收上游已完成规范值到显示值转换并格式化的数据;大型历史数据以 revision 为依据在后台 isolate 中降采样(规则 9)。
paint() - Painter 使用 ,并由同级
ExcludeSemantics/Semantics朗读显示值,同时提供冗余的非颜色通道;无障碍操作与手势命令一致(规则 10)。semanticsBuilder - Painter 中没有硬编码的方向符号;几何图形与方向无关,外围界面根据 镜像(规则 11)。
Directionality.of(context) - 表面位于 之下;如实设置
RepaintBoundary/isComplex(规则 12)。willChange - 和
scripts/check_painter_hygiene.sh通过。scripts/analyze.sh
Related skills
相关 skill
- See for the
state-management-riverpod/NotifierViewModel the View watches and the commands gestures call.AsyncNotifier - See for the small-const-widget composition the View lives inside and controller disposal.
widget-composition - See for
flutter-performancerebuild scoping,.selectbudgeting, and off-isolate work.RepaintBoundary - See 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
design-system-structure. It owns theBuildContextreduced-motion helper this skill's animation path defers to.resolveMotion - See for the never-colour-alone, MediaQuery-a11y-flag, redundant-channel, 44px-target, and
accessibility-as-coderules the Semantics here obey.sortKey - See for the canonical-store + localize-at-render contract that feeds display values into
i18n-rtl-l10n.paint() - See for what animation on this surface commits to: one haptic per committed gesture, interruptibility, and the declared reduced-motion end state.
motion-and-haptics - See for pinning the painted surface with a golden on real fonts.
widget-golden-and-a11y-testing
- 参阅 ,了解 View 监听且手势调用命令的
state-management-riverpod/NotifierViewModel。AsyncNotifier - 参阅 ,了解 View 所在的小型 const widget 组合方式以及 controller 的释放方式。
widget-composition - 参阅 ,了解
flutter-performance重建范围、.select预算和后台 isolate 工作。RepaintBoundary - 参阅 ,了解主题颜色、hairline/形状以及 reduced-motion token;View 会在 widget 层快照这些值,并将其传入 painter/Scene 字段——painter 绝不读取
design-system-structure。该 skill 拥有本动画路径所依赖的BuildContextreduced-motion helper。resolveMotion - 参阅 ,了解此处 Semantics 遵循的“不可仅依赖颜色”、MediaQuery 无障碍标记、冗余通道、44px 目标和
accessibility-as-code规则。sortKey - 参阅 ,了解为
i18n-rtl-l10n提供显示值的“规范存储 + 渲染时本地化”契约。paint() - 参阅 ,了解此表面上的动画应提交哪些反馈:每次提交手势仅一次触觉反馈、可中断性,以及声明的 reduced-motion 最终状态。
motion-and-haptics - 参阅 ,了解如何使用真实字体和 golden test 固定绘制表面。
widget-golden-and-a11y-testing
References
参考资料
- Flutter API — (
CustomPainter,paint,shouldRepaint,semanticsBuilder): https://api.flutter.dev/flutter/rendering/CustomPainter-class.htmlrepaint - Flutter API — (
CustomPaint,isComplex,willChange): https://api.flutter.dev/flutter/widgets/CustomPaint-class.htmlforegroundPainter - Flutter API — : https://api.flutter.dev/flutter/dart-ui/Canvas-class.html
Canvas - Flutter API — /
PictureRecorder(rasterize-once ID buffer): https://api.flutter.dev/flutter/dart-ui/PictureRecorder-class.htmlPicture.toImage - Flutter — Taps, drags, and other gestures (arena, ): https://docs.flutter.dev/ui/interactivity/gestures
localPosition - Flutter — Performance best practices (,
RepaintBoundary): https://docs.flutter.dev/perf/best-practicessaveLayer - Flutter API — : https://api.flutter.dev/flutter/painting/RoundedSuperellipseBorder-class.html
RoundedSuperellipseBorder - Flutter API — : https://api.flutter.dev/flutter/painting/TextPainter-class.html
TextPainter - Flutter — Accessibility & Semantics (): https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility
CustomPainterSemantics - W3C — WCAG 2.2 §1.4.1 Use of Color: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html
- Flutter API——(
CustomPainter、paint、shouldRepaint、semanticsBuilder):https://api.flutter.dev/flutter/rendering/CustomPainter-class.htmlrepaint - Flutter API——(
CustomPaint、isComplex、willChange):https://api.flutter.dev/flutter/widgets/CustomPaint-class.htmlforegroundPainter - Flutter API——:https://api.flutter.dev/flutter/dart-ui/Canvas-class.html
Canvas - Flutter API——/
PictureRecorder(仅栅格化一次的 ID 缓冲区):https://api.flutter.dev/flutter/dart-ui/PictureRecorder-class.htmlPicture.toImage - Flutter——点击、拖拽及其他手势(arena、):https://docs.flutter.dev/ui/interactivity/gestures
localPosition - Flutter——性能最佳实践(、
RepaintBoundary):https://docs.flutter.dev/perf/best-practicessaveLayer - Flutter API——:https://api.flutter.dev/flutter/painting/RoundedSuperellipseBorder-class.html
RoundedSuperellipseBorder - Flutter API——:https://api.flutter.dev/flutter/painting/TextPainter-class.html
TextPainter - Flutter——无障碍与 Semantics():https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility
CustomPainterSemantics - W3C——WCAG 2.2 §1.4.1 颜色的使用:https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html