mcpField
C
custom-canvas-and-gestures

custom-canvas-and-gestures

github.com/zakariaf开发63161 次安装5 周前更新
代码仓库

关于 custom-canvas-and-gestures

面向 Flutter 开发者的自定义画布技能,说明如何分离 View、Painter 与 Scene,并协调绘制、手势和无障碍支持,适用于构建交互式手绘界面。

主要功能

  1. 01分离 View、Painter 与不可变 Scene
  2. 02绘制与命中测试共享变换
  3. 03按几何类型选择命中测试方法
  4. 04避免在 paint 中分配对象
  5. 05手势与无障碍语义规范
  6. 06画布代码检查脚本

适用场景

  1. 01设计交互式图表或画布
  2. 02让指针命中位置与绘制对齐
  3. 03为自定义界面添加屏幕阅读器支持
  4. 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.dart
scene_painter.dart
text_fitter.dart
references/
gestures-and-semantics.md
painter-and-scene.md
text-and-shapes.md
scripts/
analyze.sh
check_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。

不可妥协的规则

  1. 画布表面由三个协作者组成——View、Painter 和不可变 Scene,绝不能合并成一个全能组件。 View 监听 ViewModel,构建一个不可变的 Scene 值类型,其中包含绘制器所需的一切,且不包含多余内容,然后将其交给 CustomPaint。Painter extends CustomPainter 必须保持简单:不能包含 Notifier、BuildContext、DateTime 或领域规则——它只绘制 Scene,绝不决定状态。简单的绘制器易于测试,也易于更换外观。

  2. shouldRepaint 只比较一个值——old.scene != scene——不做其他操作。 由于 Scene 具有值相等性,这样既正确又高效。=> true 会导致每帧都重绘;错误地返回 false 则会冻结表面——两种情况都不会显式报错。由配置驱动的重绘通过 shouldRepaint 处理;逐帧动画通过 repaint: Listenable 处理。这两条路径绝不能导致重复重绘。

  3. 只有一个仿射变换负责将逻辑空间映射到画布像素,反向亦然;Painter 和命中测试器都必须读取它。 根据传入的 Size 在每次布局时构建一次变换;提供互为精确逆变换的 toCanvas/toLogical。最具破坏性的问题,就是绘制器与命中测试器相差几个像素;使用经过测试的共用变换可以杜绝这一问题。命中测试器绝不根据 size 重新推导缩放比例——它调用 transform.toLogical。

  4. 按几何类别进行命中测试——格点使用整数,不规则区域使用栅格化 ID 缓冲区——热路径中绝不调用 Path.contains。 网格单元通过 (x ~/ cell, y ~/ cell) 确定,每个轴只需一次除法。不规则区域通过索引栅格化的 cell → RegionId Uint32List 来判定,该缓冲区只在加载时栅格化一次。这种方法为 O(1) 且不受凹形影响;每次触摸时都针对 N 个凹多边形测试点击则不然。

  5. 绝不在 paint() 内分配对象。 将所有 Paint、Path 和 Gradient 预先存为绘制器字段,并逐个修改 .color,而不是重新构造对象。在 paint() 内调用 Paint()/Path() 是造成卡顿的典型原因;只有确实需要组不透明度或混合时才使用 saveLayer,普通填充绝不使用。

  6. 所有动画都必须由传入绘制器 repaint: Listenable 的 AnimationController 驱动;绝不在 ticker 中调用 setState/notifyListeners。 这样可以直接重绘绘制器,而不重建 widget 树。通过 design-system-structure 的减少动态效果令牌或 resolveMotion 辅助函数确定动画时长(该规则由它负责——应直接收缩为零,而不是减弱效果);禁用动态效果时,应直接跳到结束状态,而非使用补间动画。状态变化绝不能只依赖动态效果或色调作为唯一信号——按照 accessibility-as-code 的要求,增加一个冗余的非颜色信号(形状 + 标签 + 触觉反馈)。

  7. 手势处理器是纯翻译器:指针 → localPosition → 共享变换 → 命中测试 → 类型化不可变命令 → ViewModel 方法。它不修改任何内容。 读取 TapDownDetails.localPosition/DragUpdateDetails.localPosition(绝不读取 globalPosition),通过 transform.toLogical 映射,然后调用 ref.read(vmProvider.notifier).<command>(...)。将 CustomPaint 包在 HitTestBehavior.opaque 中,确保整个矩形区域(包括透明间隙)都能响应操作。

  8. 一个动词对应一个识别器;在手势竞技场中显式仲裁冲突。 主要动词使用单个识别器;次要动词使用独立的识别器。如果两个识别器都可能认领同一个指针,请通过 RawGestureDetector 解决冲突——不要寄希望于运气。轴向锁定拖动是一个夹取操作(拖动开始时快照边界),不是逐帧碰撞检测。

  9. 输入为规范值,输出为显示值——绝不在 paint() 中转换单位、格式化日期或整形数字。 画师接收的值已经在上游转换为显示单位并完成格式化;它只负责将数字映射为像素。大型历史数据应通过 Isolate.run 在 isolate 外分桶/降采样,并以修订计数器为依据进行缓存——绝不要在 paint() 中遍历数千个数据点。

  10. 自定义绘制的表面对屏幕阅读器不透明——请明确编写 Semantics。 可以将 CustomPaint 包在 ExcludeSemantics 中,并在旁边放置一个播报显示值(答案,而不是形状)的 Semantics 节点;也可以从 semanticsBuilder 返回 List<CustomPainterSemantics>,为每个元素创建节点。颜色绝不是唯一的信息通道——仅凭颜色区分禁则由 accessibility-as-code 负责。无障碍操作提交的命令必须与手势相同。

  11. 几何布局不区分方向,只有界面装饰会镜像。 绘制的数据、物理拖动和波形都没有左右之分——绝不要为 RTL 翻转它们。图例、工具提示、坐标轴位置和标签起始边缘需要镜像。通过 Directionality.of(context) 获取方向符号,并使用 *Directional 内边距——绝不要硬编码 Offset(-x, y)、.left 或 .right。

  12. 将绘制表面隔离在 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/AsyncNotifier ViewModel,以及手势调用的命令,请参阅 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。

参考资料

评论

支持 Markdown