关于 custom-canvas-and-gestures
面向 Flutter 开发者的自定义画布技能,说明如何分离 View、Painter 与 Scene,并协调绘制、手势和无障碍支持,适用于构建交互式手绘界面。
主要功能
- 01分离 View、Painter 与不可变 Scene
- 02绘制与命中测试共享变换
- 03按几何类型选择命中测试方法
- 04避免在 paint 中分配对象
- 05手势与无障碍语义规范
- 06画布代码检查脚本
适用场景
- 01设计交互式图表或画布
- 02让指针命中位置与绘制对齐
- 03为自定义界面添加屏幕阅读器支持
- 04在提交 PR 前检查画布代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill custom-canvas-and-gestures安装后在 Claude Code 中输入/custom-canvas-and-gestures确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/gesture_translator.dartscene_painter.darttext_fitter.dartreferences/gestures-and-semantics.mdpainter-and-scene.mdtext-and-shapes.mdscripts/analyze.shcheck_painter_hygiene.sh详细介绍
自定义画布与手势
手绘表面(CustomPainter/Canvas)的每个像素都由自身绘制,因此在不同平台上看起来可以逐字节一致——但前提是绘制器保持简单、坐标计算共用,并且手势只负责转换而不作决策。本技能定义了这一层的契约:如何划分职责、像素与指针如何保持一致,以及屏幕阅读器如何读取画布。
根据当前任务阅读相应参考文档:
references/painter-and-scene.md— View/Painter/Scene 的职责划分、共用变换、按几何类别进行命中测试、零分配的paint()、双路径重绘陷阱。references/gestures-and-semantics.md— 将手势作为转换器、手势竞技场仲裁、拖动时限制范围而非检测碰撞、自定义画布语义、操作与手势的一致性。references/text-and-shapes.md— 使用TextPainter测量并适配文本、视觉居中、RoundedSuperellipseBorder、物理像素细线、同心圆角半径、优先使用绘制器而非图片资源。
提交 PR 前运行 scripts/check_painter_hygiene.sh 和 scripts/analyze.sh。
不可妥协的规则
-
画布表面由三个协作者组成——View、Painter 和不可变 Scene,绝不能合并成一个全能组件。 View 监听 ViewModel,构建一个不可变的
Scene值类型,其中包含绘制器所需的一切,且不包含多余内容,然后将其交给CustomPaint。Painter extends CustomPainter必须保持简单:不能包含Notifier、BuildContext、DateTime或领域规则——它只绘制 Scene,绝不决定状态。简单的绘制器易于测试,也易于更换外观。 -
shouldRepaint只比较一个值——old.scene != scene——不做其他操作。 由于Scene具有值相等性,这样既正确又高效。=> true会导致每帧都重绘;错误地返回false则会冻结表面——两种情况都不会显式报错。由配置驱动的重绘通过shouldRepaint处理;逐帧动画通过repaint:Listenable处理。这两条路径绝不能导致重复重绘。 -
只有一个仿射变换负责将逻辑空间映射到画布像素,反向亦然;Painter 和命中测试器都必须读取它。 根据传入的
Size在每次布局时构建一次变换;提供互为精确逆变换的toCanvas/toLogical。最具破坏性的问题,就是绘制器与命中测试器相差几个像素;使用经过测试的共用变换可以杜绝这一问题。命中测试器绝不根据size重新推导缩放比例——它调用transform.toLogical。 -
按几何类别进行命中测试——格点使用整数,不规则区域使用栅格化 ID 缓冲区——热路径中绝不调用
Path.contains。 网格单元通过(x ~/ cell, y ~/ cell)确定,每个轴只需一次除法。不规则区域通过索引栅格化的cell → RegionIdUint32List来判定,该缓冲区只在加载时栅格化一次。这种方法为 O(1) 且不受凹形影响;每次触摸时都针对 N 个凹多边形测试点击则不然。 -
绝不在
paint()内分配对象。 将所有Paint、Path和Gradient预先存为绘制器字段,并逐个修改.color,而不是重新构造对象。在paint()内调用Paint()/Path()是造成卡顿的典型原因;只有确实需要组不透明度或混合时才使用saveLayer,普通填充绝不使用。 -
所有动画都必须由传入绘制器
repaint:Listenable的AnimationController驱动;绝不在 ticker 中调用setState/notifyListeners。 这样可以直接重绘绘制器,而不重建 widget 树。通过design-system-structure的减少动态效果令牌或resolveMotion辅助函数确定动画时长(该规则由它负责——应直接收缩为零,而不是减弱效果);禁用动态效果时,应直接跳到结束状态,而非使用补间动画。状态变化绝不能只依赖动态效果或色调作为唯一信号——按照accessibility-as-code的要求,增加一个冗余的非颜色信号(形状 + 标签 + 触觉反馈)。 -
手势处理器是纯翻译器:指针 →
localPosition→ 共享变换 → 命中测试 → 类型化不可变命令 → ViewModel 方法。它不修改任何内容。 读取TapDownDetails.localPosition/DragUpdateDetails.localPosition(绝不读取globalPosition),通过transform.toLogical映射,然后调用ref.read(vmProvider.notifier).<command>(...)。将CustomPaint包在HitTestBehavior.opaque中,确保整个矩形区域(包括透明间隙)都能响应操作。 -
一个动词对应一个识别器;在手势竞技场中显式仲裁冲突。 主要动词使用单个识别器;次要动词使用独立的识别器。如果两个识别器都可能认领同一个指针,请通过
RawGestureDetector解决冲突——不要寄希望于运气。轴向锁定拖动是一个夹取操作(拖动开始时快照边界),不是逐帧碰撞检测。 -
输入为规范值,输出为显示值——绝不在
paint()中转换单位、格式化日期或整形数字。 画师接收的值已经在上游转换为显示单位并完成格式化;它只负责将数字映射为像素。大型历史数据应通过Isolate.run在 isolate 外分桶/降采样,并以修订计数器为依据进行缓存——绝不要在paint()中遍历数千个数据点。 -
自定义绘制的表面对屏幕阅读器不透明——请明确编写 Semantics。 可以将
CustomPaint包在ExcludeSemantics中,并在旁边放置一个播报显示值(答案,而不是形状)的Semantics节点;也可以从semanticsBuilder返回List<CustomPainterSemantics>,为每个元素创建节点。颜色绝不是唯一的信息通道——仅凭颜色区分禁则由accessibility-as-code负责。无障碍操作提交的命令必须与手势相同。 -
几何布局不区分方向,只有界面装饰会镜像。 绘制的数据、物理拖动和波形都没有左右之分——绝不要为 RTL 翻转它们。图例、工具提示、坐标轴位置和标签起始边缘需要镜像。通过
Directionality.of(context)获取方向符号,并使用*Directional内边距——绝不要硬编码Offset(-x, y)、.left或.right。 -
将绘制表面隔离在
RepaintBoundary后。 它会获得独立的合成器层,因此画布的刷新不会重绘周围的界面装饰,反之亦然。应如实设置isComplex/willChange。不要给所有内容都套上边界——每个边界都会占用 GPU 内存。
View / Painter / Scene
Scene 是画师的全部输入,是不可变值类型(使用 @freezed,或手动实现带有 ==/hashCode 的 @immutable)。值相等性使 shouldRepaint 能够快速比较。
@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);
}
View 监听 ViewModel(Riverpod 的 Notifier/AsyncNotifier),将其状态映射为 Scene,并且绝不在画师内部修改状态。ViewModel 主干请参阅 state-management-riverpod。
共享变换
使用统一的 scale 和居中 origin;toLogical 是 toCanvas 的精确逆变换。画师和命中测试器都读取它。
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),
);
}
}
按几何类别进行命中测试
// 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 颜色,构建一次缓冲区:PictureRecorder → Picture.toImage → Image.toByteData,然后读取到 Uint32List 并缓存。仅在几何结构确实发生变化时重新构建。完整步骤请参阅 references/painter-and-scene.md。
零分配 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)
}
手势 → 类型化命令
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
// 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))),
),
)
对于逐元素节点(每个节点均可点击),请从 semanticsBuilder 返回 List<CustomPainterSemantics> 并重写 shouldRebuildSemantics——请参阅 references/gestures-and-semantics.md。无障碍 onTap 必须调用与手势相同的命令。
按测量宽度适配文本
使用线性 TextPainter 探测来将文本缩放到测量宽度。布局时保持不受约束——受约束的 layout() 会换行,此时 tp.width 会返回约束宽度,悄然导致每行都返回相同的尺寸。
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);
}
仅通过 fontWeight 设置字重——不要同时传入 FontVariation('wght', …);FontWeight 会驱动该轴,同时传入两者会产生冲突。使用 TextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even) 通过度量值修正光学居中——绝不要使用硬编码的像素偏移,因为文本缩放比例达到 200% 时会出问题。对于必须保持统一尺寸的内容,绝不要使用 FittedBox/自动缩小。详情见 references/text-and-shapes.md。
形状与细线
- 对于重复的矢量细节,优先使用
CustomPainter,而不是图像资源——它在任何 DPR 和主题下都能保持清晰。对于大量相同标记,应在一次Canvas绘制过程中绘制,而不是创建 N 个组件。 - 一方提供的圆角超椭圆:
RoundedSuperellipseBorder/ClipRSuperellipse/Canvas.drawRSuperellipse均为一方提供,并带有 Impeller GPU 路径。不要使用figma_squircle/smooth_corner;ContinuousRectangleBorder不是 iOS 级别的圆角超椭圆。 - 真正的细线只有一个物理像素宽:
1.0 / MediaQuery.devicePixelRatioOf(context),并设置strokeAlign: BorderSide.strokeAlignInside。Border.all()默认宽度为 1.0 个逻辑像素,在现代手机上约等于 3 个物理像素——这是表格边框,不是细线。 - 嵌套圆角在结构上同心:
inner = outer - padding,通过计算得出,绝不要再使用一个会产生偏差的常量。
反面模式
shouldRepaint(_) => true,或在shouldRepaint中深度遍历可变对象——Scene 是值类型;应直接比较它(规则 2)。- 包办一切、持有状态、在
paint()内读取ref,或在绘制器中判断规则的组件——绘制器应保持简单(规则 1)。 - 根据
size重新推导scale,或使用globalPosition计算的命中测试器——始终使用transform.toLogical(localPosition)(规则 3、7)。 - 在触摸路径中使用
Path.contains(tap),或在每次点击/帧中重新栅格化 ID 缓冲区——几何形状是稳定的(规则 4)。 - 在
paint()内分配Paint()/Path()(可提到外部时),每帧创建ui.Gradient,或为普通填充使用saveLayer(规则 5)。 - 每个动画帧都通过
setState/notifyListeners运行动画——应改为将控制器传入repaint:(规则 6)。 - 不可跳过的动画,或仅通过运动/色相来表示状态变化(规则 6)。
- 在
paint()内进行单位转换、日期格式化或数字塑形,或在其中循环处理原始历史记录——应在上游、隔离区之外完成(规则 9)。 - 使用不透明的
Semantics(label: 'chart')描述形状而非数值,或未在装饰性绘制器上使用ExcludeSemantics(规则 10)。 - 在绘制器中硬编码
Offset(-x, y)、.left/.right或Alignment.centerRight——应根据Directionality.of(context)推导(规则 11)。 - 在需要统一尺寸时使用
FittedBox/自动缩小;在适配器中使用受约束的TextPainter.layout()(按测量宽度适配部分)。 - 使用
Border.all()绘制细线;使用ContinuousRectangleBorder/第三方圆角超椭圆包(形状部分)。 - 使用没有
RepaintBoundary的CustomPaint,或到处盲目添加边界(规则 12)。
完成标准
- 表面由 View、
Painter extends CustomPainter和不可变的Scene组成;painter 不持有Notifier/BuildContext/DateTime/规则(规则 1)。 -
shouldRepaint仅返回old.scene != scene;逐帧动画通过repaint:流转,而不是通过shouldRepaint(规则 2、6)。 - 每次布局只构建一个变换,供 painter 和命中测试器共同读取;
toLogical/toCanvas互为精确逆变换;命中测试器不会重新推导缩放比例(规则 3)。 - 命中测试使用整数格点运算,或使用只栅格化一次的
Uint32List区域 ID 缓冲区;热路径中不使用Path.contains;目标尺寸不小于约 44 pt(规则 4、7)。 -
paint()不分配任何可提升为字段的对象——Paint/Path/Gradient都是字段,按元素修改.color,仅在确实需要组混合时使用saveLayer(规则 5)。 - 动画由
AnimationController通过repaint:驱动;动效通过design-system-structure的resolveMotion解析(禁用动效时直接跳至最终状态);状态变化不能仅依赖色相或动效(规则 6)。 - 手势处理程序将
localPosition转换为变换 → 命中测试 → 类型化命令 → ViewModel 方法,且不修改任何内容;CustomPaint位于HitTestBehavior.opaque下(规则 7)。 - 每种操作动词只使用一个识别器;通过
RawGestureDetector解决手势竞技场冲突;轴向锁定的拖动会限制在拖动开始时的边界内(规则 8)。 -
paint()接收上游格式化后的规范值 → 显示值;大型历史数据按修订版本标记,在 isolate 外进行降采样(规则 9)。 - painter 使用
ExcludeSemantics;同级的Semantics/semanticsBuilder会播报显示值,并提供冗余的非颜色通道;无障碍操作与手势命令一致(规则 10)。 - 任何 painter 中都没有硬编码的方向符号;几何不依赖方向,界面装饰则根据
Directionality.of(context)镜像(规则 11)。 - 表面位于
RepaintBoundary下;如实设置isComplex/willChange(规则 12)。 -
scripts/check_painter_hygiene.sh和scripts/analyze.sh均通过。
相关技能
- 关于 View 观察的
Notifier/AsyncNotifierViewModel,以及手势调用的命令,请参阅state-management-riverpod。 - 关于 View 所处的小型 const widget 组合和控制器释放,请参阅
widget-composition。 - 关于
.select重建范围、RepaintBoundary预算和 isolate 外的工作,请参阅flutter-performance。 - 关于主题颜色、细线/形状和减少动效令牌,请参阅
design-system-structure。View 会在 widget 层对这些内容进行快照,并传入 painter/Scene 字段——painter 不会读取BuildContext。该技能提供resolveMotion减少动效辅助函数,本技能的动画路径会交由它处理。 - 关于 Semantics 遵循的颜色不能作为唯一信息、MediaQuery 无障碍标志、冗余通道、44px 目标尺寸和
sortKey规则,请参阅accessibility-as-code。 - 关于将规范数据存储与渲染时本地化相结合,并向
paint()提供显示值的约定,请参阅i18n-rtl-l10n。 - 关于此表面上的动画所承诺的内容——每次已提交手势触发一次触觉反馈、动画可中断,以及声明减少动效时的最终状态——请参阅
motion-and-haptics。 - 关于使用真实字体为绘制表面固定 golden 测试,请参阅
widget-golden-and-a11y-testing。
参考资料
- 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(仅栅格化一次的 ID 缓冲区):https://api.flutter.dev/flutter/dart-ui/PictureRecorder-class.html - Flutter — 点击、拖动和其他手势(竞技场、
localPosition):https://docs.flutter.dev/ui/interactivity/gestures - Flutter — 性能最佳实践(
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 — 无障碍与语义(
CustomPainterSemantics):https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility - W3C — WCAG 2.2 §1.4.1 颜色的使用:https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html