custom-canvas-and-gestures

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Custom Canvas & Gestures

自定义 Canvas 与手势

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.
由手绘表面(
CustomPainter
/
Canvas
)掌控每一个像素,因此只有当 painter 保持 dumb、坐标计算共享,且手势只负责转换而不负责决策时,它们才能在不同平台上呈现逐字节一致的效果。本 skill 是该层的契约:如何拆分各部分、如何让像素与指针保持一致,以及如何让屏幕阅读器读懂 canvas。
阅读与当前任务对应的参考资料:
  • references/painter-and-scene.md
    ——View/Painter/Scene 拆分、共享 transform、按几何类型进行 hit-test、零分配的
    paint()
    ,以及双路径 repaint 陷阱。
  • references/gestures-and-semantics.md
    ——gesture-as-translator、arena 仲裁、采用 clamp 而非碰撞检测的拖拽、自定义 canvas 的 Semantics,以及操作与手势的一致性。
  • references/text-and-shapes.md
    ——经测量的
    TextPainter
    文本适配、光学居中、
    RoundedSuperellipseBorder
    、物理像素级 hairline、同心圆角,以及 painter 优于图片资源的场景。
提交 PR 前运行
scripts/check_painter_hygiene.sh
和
scripts/analyze.sh
。

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.
  1. 一个 canvas 表面由三个协作者组成——一个 View、一个 Painter 和一个不可变 Scene——绝不能是一个巨型 widget。 View 监听 ViewModel,构建一个不可变的
    Scene
    值类型,其中包含 painter 所需的一切且不多余,并将其交给
    CustomPaint
    。
    Painter extends CustomPainter
    保持 dumb:不含
    Notifier
    、不读取
    BuildContext
    、不使用
    DateTime
    、不包含领域规则——它只绘制 Scene,绝不决定状态。dumb painter 可测试,也可轻松重新换肤。
  2. shouldRepaint
    只做一次值比较——
    old.scene != scene
    ——不做其他任何事。
    由于
    Scene
    具有值相等性,这种方式既正确又开销低。
    => true
    会每帧重绘;错误的
    false
    会冻结表面——两者都会静默失败。配置驱动的重绘通过
    shouldRepaint
    完成;逐帧动画则通过
    repaint:
    Listenable
    完成。这两条路径绝不能导致双重重绘。
  3. 只有一个仿射 transform 负责映射逻辑空间 ↔ canvas 像素,并且绘制器与命中测试器都要读取它。 每次布局时,根据传入的
    Size
    构建一次;将
    toCanvas
    /
    toLogical
    暴露为互为精确逆运算的方法。绘制器与命中测试器相差几个像素,是最具破坏性的 bug;共享且经过测试的 transform 可以从根本上杜绝这种问题。命中测试器绝不从
    size
    重新推导 scale
    ——它应调用
    transform.toLogical
    。
  4. 按几何类型进行 hit-test——网格使用整数,不规则区域使用栅格化的 ID 缓冲区——热路径中绝不使用
    Path.contains
    。
    网格单元为
    (x ~/ cell, y ~/ cell)
    ,每个坐标轴只需一次除法。不规则区域通过索引在加载时仅栅格化一次的
    cell → RegionId
    Uint32List
    进行解析。其复杂度为 O(1),且不受区域凹凸影响;不应在每次触摸时都对 N 个凹多边形执行测试。
  5. 不要在
    paint()
    内部分配任何对象。
    将每个
    Paint
    、
    Path
    和
    Gradient
    预先计算为 painter 字段,并按元素修改
    .color
    ,而不是重新构造。在
    paint()
    内调用
    Paint()
    /
    Path()
    是卡顿的经典来源;仅在确实需要组透明度或混合效果时使用
    saveLayer
    ,普通填充绝不使用它。
  6. 所有动画都由作为 painter 的
    repaint:
    Listenable
    传入的
    AnimationController
    驱动——绝不在 ticker 中调用
    setState
    /
    notifyListeners
    。
    这会直接重绘 painter,而无需重建 widget 树。通过
    design-system-structure
    的 reduced-motion token/
    resolveMotion
    helper 解析动画时长(该 helper 负责此规则——直接折叠为零,而非仅仅减弱);动态效果被抑制时,应直接切换到最终状态,而不是执行 tween。状态变化绝不能仅依靠动态效果或色相作为信号——应按照
    accessibility-as-code
    的要求,提供冗余的非颜色通道,例如形状、标签和触觉反馈。
  7. 手势处理器是纯转换器:pointer →
    localPosition
    → 共享 transform → hit-test → 类型化的不可变命令 → ViewModel 方法。它不修改任何状态。
    读取
    TapDownDetails.localPosition
    /
    DragUpdateDetails.localPosition
    (绝不使用
    globalPosition
    ),通过
    transform.toLogical
    映射坐标,然后调用
    ref.read(vmProvider.notifier).<command>(...)
    。将
    CustomPaint
    包裹在
    HitTestBehavior.opaque
    中,使整个矩形在透明空隙上方也处于可交互状态。
  8. 一个手势动词对应一个 recognizer;在 arena 中显式仲裁冲突。 主要手势动词独占一个 recognizer;次要手势动词使用各自的 recognizer。当两个 recognizer 都可能认领同一 pointer 时,通过
    RawGestureDetector
    解决——不要依赖默认行为。轴锁定拖拽采用 clamp(边界在拖拽开始时快照固定),而不是逐帧碰撞检测。
  9. 规范值输入,显示值输出——绝不在
    paint()
    内换算单位、格式化日期或处理数字字形。
    Painter 接收上游已经转换为显示单位并完成格式化的值;它只负责将数字映射为像素。通过
    Isolate.run
    在后台 isolate 中,以 revision counter 为依据,对大型历史数据进行分桶或降采样——绝不在
    paint()
    中循环处理数千个点。
  10. 自绘表面对于屏幕阅读器是 opaque 的——必须显式编写 Semantics。 要么将
    CustomPaint
    包裹在
    ExcludeSemantics
    中,并使用同级
    Semantics
    节点朗读显示值(答案,而不是形状);要么从
    semanticsBuilder
    返回
    List<CustomPainterSemantics>
    ,为每个元素创建节点。颜色绝不能是唯一通道——“不可仅依赖颜色”的规则由
    accessibility-as-code
    负责。无障碍操作必须执行与手势相同的命令。
  11. 几何图形与方向无关;只有外围界面才会镜像。 绘制的数据、实际拖拽和波形都没有左右手性——绝不能为 RTL 进行翻转。图例、工具提示、坐标轴位置和标签前缘则应镜像。任何方向符号都应从
    Directionality.of(context)
    推导,并使用
    *Directional
    insets——绝不要硬编码
    Offset(-x, y)
    、
    .left
    或
    .right
    。
  12. 将表面隔离在
    RepaintBoundary
    后面。
    它会获得独立的合成器层,因此 canvas 的一帧更新不会重绘周围界面,反之亦然。应如实设置
    isComplex
    /
    willChange
    。不要无差别地包裹所有内容——每个边界都会消耗 GPU 内存。

View / Painter / Scene

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

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
Notifier
/
AsyncNotifier
), projects its state into the Scene, and never mutates state from inside the painter. See
state-management-riverpod
for the ViewModel spine.
Scene 是 painter 的完整输入,是一个不可变值类型(
@freezed
,或手写
@immutable
并实现
==
/
hashCode
)。值相等性使
shouldRepaint
可以通过一次 inexpensive 比较完成。
dart

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
Notifier
/
AsyncNotifier
),将其状态投射为 Scene,并且绝不在 painter 内部修改状态有关 ViewModel 架构,请参阅
state-management-riverpod
。

The shared transform

共享 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),
    );
  }
}
使用单一统一的
scale
和用于居中的
origin
;
toLogical
是
toCanvas
的精确逆运算。Painter 与 hit-tester 均读取它。
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

按几何类型进行 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
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
.
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];
}
通过将每个区域以唯一 id-colour 绘制到
PictureRecorder
→
Picture.toImage
→
Image.toByteData
中,一次性构建缓冲区,将其读取为
Uint32List
并缓存。仅在几何结构实际发生变化时重建。完整方案请参阅
references/painter-and-scene.md
。

Zero-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
List<CustomPainterSemantics>
from
semanticsBuilder
and override
shouldRebuildSemantics
— see
references/gestures-and-semantics.md
. The a11y
onTap
must call the same command the gesture does.
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))),
  ),
)
对于按元素划分的节点(每个节点都可点击),从
semanticsBuilder
返回
List<CustomPainterSemantics>
,并重写
shouldRebuildSemantics
——请参阅
references/gestures-and-semantics.md
。无障碍
onTap
必须调用与手势相同的命令。

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
.
使用线性的
TextPainter
探针将文本缩放到实测宽度。布局时不要施加约束——受约束的
layout()
会换行,而
tp.width
随后只会报告约束值,导致每一行都静默返回相同尺寸。
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);
}
仅通过
fontWeight
设置字重——不要同时传入
FontVariation('wght', …)
;
FontWeight
会驱动该轴,同时传入两者会产生冲突。使用
TextHeightBehavior(applyHeightToFirstAscent: false, applyHeightToLastDescent: false, leadingDistribution: TextLeadingDistribution.even)
以度量方式修正光学居中——绝不要使用硬编码的像素微调,否则在 200% 文本缩放下会失效。对于必须保持统一尺寸的内容,绝不要使用
FittedBox
/自动缩小。详情请参阅
references/text-and-shapes.md
。

Shapes & hairlines

形状与 hairline

  • 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.
  • 对于重复出现的矢量细节,优先使用
    CustomPainter
    而非图片资源——它在所有 DPR 和主题下都能保持清晰。对于大量相同标记,应在一次
    Canvas
    pass 中完成绘制,而不是创建 N 个 widget。
  • Flutter 官方 squircle:
    RoundedSuperellipseBorder
    /
    ClipRSuperellipse
    /
    Canvas.drawRSuperellipse
    均为官方实现,并支持 Impeller GPU 路径。不要使用
    figma_squircle
    /
    smooth_corner
    ;
    ContinuousRectangleBorder
    并非 iOS 级别的 squircle。
  • 真正的 hairline 是一个物理像素:使用
    1.0 / MediaQuery.devicePixelRatioOf(context)
    ,并设置
    strokeAlign: BorderSide.strokeAlignInside
    。
    Border.all()
    默认使用 1.0 逻辑像素,在现代手机上约为 3 个物理像素——那是表格边框,而不是 hairline。
  • 嵌套圆角在构造时天然保持同心:
    inner = outer - padding
    ,应通过计算得出,绝不能使用第二个会逐渐偏离的常量。

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).
  • shouldRepaint(_) => true
    ,或者
    shouldRepaint
    深度遍历可变对象——Scene 是值类型;直接比较它(规则 2)。
  • 巨型 widget 持有状态、在
    paint()
    内读取
    ref
    ,或者由 painter 决定规则——painter 必须保持 dumb(规则 1)。
  • 命中测试器从
    size
    重新推导
    scale
    ,或者基于
    globalPosition
    进行计算——始终使用
    transform.toLogical(localPosition)
    (规则 3、7)。
  • 在触摸路径中使用
    Path.contains(tap)
    ,或者每次点击/每帧都重新栅格化 ID 缓冲区——该缓冲区在几何结构稳定时无需重建(规则 4)。
  • 在
    paint()
    内分配可提升的
    Paint()
    /
    Path()
    、逐帧创建
    ui.Gradient
    ,或者为普通填充使用
    saveLayer
    (规则 5)。
  • 每个 ticker 帧都通过
    setState
    /
    notifyListeners
    驱动动画——应改为传入 controller 作为
    repaint:
    (规则 6)。
  • 动画无法跳过,或者仅以动态效果/色相作为状态变化的唯一信号(规则 6)。
  • 在
    paint()
    内换算单位、格式化日期、处理数字字形或循环处理原始历史数据——这些工作应在上游、后台 isolate 中完成(规则 9)。
  • 使用一个不透明的
    Semantics(label: 'chart')
    描述形状而不是数值,或者未对装饰性 painter 使用
    ExcludeSemantics
    (规则 10)。
  • 在 painter 中硬编码
    Offset(-x, y)
    、
    .left
    /
    .right
    或
    Alignment.centerRight
    ——应从
    Directionality.of(context)
    推导(规则 11)。
  • 在要求统一尺寸的场景中使用
    FittedBox
    /自动缩小;在文本适配器中使用受约束的
    TextPainter.layout()
    (经测量的文本适配部分)。
  • 使用
    Border.all()
    绘制 hairline;使用
    ContinuousRectangleBorder
    /第三方 squircle 包(形状部分)。
  • CustomPaint
    没有
    RepaintBoundary
    ,或者到处无差别添加边界(规则 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.
  • 表面由 View +
    Painter extends CustomPainter
    + 不可变 Scene 组成;painter 不持有
    Notifier
    /
    BuildContext
    /
    DateTime
    /业务规则(规则 1)。
  • shouldRepaint
    仅返回
    old.scene != scene
    ;逐帧动画通过
    repaint:
    流动,而不是通过
    shouldRepaint
    (规则 2、6)。
  • 每次布局只构建一个 transform,并由 painter 与 hit-tester 共同读取;
    toLogical
    /
    toCanvas
    互为精确逆运算;hit-tester 绝不重新推导 scale(规则 3)。
  • Hit-test 使用整数网格计算,或使用仅栅格化一次的
    Uint32List
    region-ID 缓冲区;热路径中不使用
    Path.contains
    ;目标尺寸不低于约 44 pt(规则 4、7)。
  • paint()
    不分配任何可提升的对象——
    Paint
    /
    Path
    /
    Gradient
    均作为字段,按元素修改
    .color
    ,仅在真正需要组混合时使用
    saveLayer
    (规则 5)。
  • 动画由
    AnimationController
    作为
    repaint:
    驱动;动态效果通过
    design-system-structure
    的
    resolveMotion
    解析(被抑制时直接切换到最终状态);状态变化不依赖色相或动态效果(规则 6)。
  • 手势处理器将
    localPosition
    → transform → hit-test → 类型化命令 → ViewModel 方法,并且不修改任何状态;
    CustomPaint
    位于
    HitTestBehavior.opaque
    之下(规则 7)。
  • 每个手势动词对应一个 recognizer;arena 冲突通过
    RawGestureDetector
    解决;轴锁定拖拽会 clamp 到拖拽开始时的边界(规则 8)。
  • paint()
    接收上游已完成规范值到显示值转换并格式化的数据;大型历史数据以 revision 为依据在后台 isolate 中降采样(规则 9)。
  • Painter 使用
    ExcludeSemantics
    ,并由同级
    Semantics
    /
    semanticsBuilder
    朗读显示值,同时提供冗余的非颜色通道;无障碍操作与手势命令一致(规则 10)。
  • Painter 中没有硬编码的方向符号;几何图形与方向无关,外围界面根据
    Directionality.of(context)
    镜像(规则 11)。
  • 表面位于
    RepaintBoundary
    之下;如实设置
    isComplex
    /
    willChange
    (规则 12)。
  • scripts/check_painter_hygiene.sh
    和
    scripts/analyze.sh
    通过。

Related skills

相关 skill

  • 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.
  • 参阅
    state-management-riverpod
    ,了解 View 监听且手势调用命令的
    Notifier
    /
    AsyncNotifier
    ViewModel。
  • 参阅
    widget-composition
    ,了解 View 所在的小型 const widget 组合方式以及 controller 的释放方式。
  • 参阅
    flutter-performance
    ,了解
    .select
    重建范围、
    RepaintBoundary
    预算和后台 isolate 工作。
  • 参阅
    design-system-structure
    ,了解主题颜色、hairline/形状以及 reduced-motion token;View 会在 widget 层快照这些值,并将其传入 painter/Scene 字段——painter 绝不读取
    BuildContext
    。该 skill 拥有本动画路径所依赖的
    resolveMotion
    reduced-motion helper。
  • 参阅
    accessibility-as-code
    ,了解此处 Semantics 遵循的“不可仅依赖颜色”、MediaQuery 无障碍标记、冗余通道、44px 目标和
    sortKey
    规则。
  • 参阅
    i18n-rtl-l10n
    ,了解为
    paint()
    提供显示值的“规范存储 + 渲染时本地化”契约。
  • 参阅
    motion-and-haptics
    ,了解此表面上的动画应提交哪些反馈:每次提交手势仅一次触觉反馈、可中断性,以及声明的 reduced-motion 最终状态。
  • 参阅
    widget-golden-and-a11y-testing
    ,了解如何使用真实字体和 golden test 固定绘制表面。

References

参考资料