mcpField
C
custom-canvas-and-gestures

custom-canvas-and-gestures

github.com/zakariaf開発63161 回インストール5 週間前に更新
リポジトリ

custom-canvas-and-gestures について

カスタム描画キャンバス向けの Flutter 開発スキルです。View、Painter、Scene の分離を軸に、描画、ジェスチャー、アクセシビリティを整合させる方法を扱います。

主な機能

  1. 01View・Painter・不変 Scene の分離
  2. 02描画とヒットテストで共有する変換
  3. 03形状に応じたヒットテスト方式
  4. 04paint 内の割り当て回避
  5. 05ジェスチャーとアクセシビリティの指針
  6. 06キャンバス向け検査スクリプト

活用シーン

  1. 01インタラクティブなチャートやキャンバスの設計
  2. 02ポインター判定と描画位置の整合
  3. 03カスタム画面へのスクリーンリーダー対応
  4. 04PR 前のキャンバスコード確認

インストール

コマンドをコピーしてターミナルで実行します。

$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()、2 つの再描画経路に関する落とし穴。
  • references/gestures-and-semantics.md — 翻訳役としてのジェスチャー、ジェスチャーアリーナの調停、衝突ではなくクランプするドラッグ、カスタムキャンバスの Semantics、アクションとジェスチャーの同等性。
  • references/text-and-shapes.md — TextPainter による計測とフィッティング、視覚的な中央揃え、RoundedSuperellipseBorder、物理ピクセル単位のヘアライン、同心円の半径、画像アセットよりペインターを優先する方法。

PR の前に scripts/check_painter_hygiene.sh と scripts/analyze.sh を実行してください。

必ず守るルール

  1. キャンバスサーフェスは View、Painter、イミュータブルな Scene の 3 者で構成します。単一の巨大ウィジェットにはしません。 View は ViewModel を監視し、ペインターに必要なものだけをすべて含むイミュータブルな Scene 値型を構築して、CustomPaint に渡します。Painter extends CustomPainter は余計な判断をしません。Notifier、BuildContext、DateTime、ドメインルールは持たず、Scene を描画するだけで状態を決定しません。余計な判断をしないペインターはテストしやすく、スキンの変更も容易です。

  2. shouldRepaint は old.scene != scene という値の比較だけにします。それ以外は何も行いません。 Scene は値の等価性を持つため、これが正確かつ低コストな方法です。=> true は毎フレーム再描画し、誤った false はサーフェスを更新不能にします。どちらもエラーなく問題を引き起こします。設定による再描画には shouldRepaint を使い、フレームごとのアニメーションには repaint: の Listenable を使います。この 2 つの経路で再描画を二重に起こしてはいけません。

  3. 論理空間とキャンバスのピクセル間の変換には、アフィン変換を 1 つだけ使い、ペインターとヒットテスターの両方から参照します。 入力された Size からレイアウトごとに一度だけ変換を作成し、toCanvas/toLogical を厳密な逆変換として公開します。最も深刻な不具合は、ペインターとヒットテスターの座標が数ピクセルずれることです。変換を共有してテストすれば、これを防げます。ヒットテスターは size からスケールを再計算せず、transform.toLogical を呼び出します。

  4. ヒットテストはジオメトリクラスに応じて行います。格子には整数、複雑な領域にはラスタライズ済み ID バッファを使い、ホットパスでは Path.contains を使いません。 グリッドセルは (x ~/ cell, y ~/ cell) で求め、軸ごとに 1 回だけ除算します。複雑な領域は、cell → RegionId の Uint32List をインデックスとして使い、ロード時に一度だけラスタライズします。これなら O(1) で、凹形にも対応できます。タップのたびに N 個の凹多角形と照合する方法は適切ではありません。

  5. paint() の中では何も割り当てません。 すべての Paint、Path、Gradient をペインターのフィールドとして事前に用意し、要素ごとに .color を変更して使います。paint() 内で Paint()/Path() を作成するのは、カクつきの典型的な原因です。saveLayer は実際にグループ不透明度やブレンドが必要な場合に限って使い、単純な塗りつぶしには使いません。

  6. すべてのアニメーションは、ペインターの repaint: Listenable として渡す AnimationController で駆動します。ティッカー内で setState/notifyListeners を呼び出してはいけません。 これによりウィジェットツリーを再構築せず、ペインターを直接再描画できます。アニメーションの継続時間は、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. 動詞1つにつき認識器1つ。アリーナでの競合は明示的に調停します。 主な動詞には認識器を1つだけ割り当て、副次的な動詞にはそれぞれ別の認識器を割り当てます。同じポインターを2つが認識できる場合は、期待に頼らず RawGestureDetector で解決します。軸ロックされたドラッグは、ドラッグ開始時の境界を固定するクランプであり、フレームごとの衝突検出ではありません。

  9. 正規の値を入力し、表示用の値を出力します。paint() 内で単位変換、日付の書式設定、数字の形の調整をしてはいけません。 ペインターには、あらかじめ表示単位に変換され、上流で書式設定された値を渡し、ペインターは数値をピクセルに対応付けます。大量の履歴データは、リビジョンカウンターをキーとして Isolate.run を使い、アイソレート外でバケット化/ダウンサンプリングします。paint() 内で数千個の点をループしてはいけません。

  10. 独自描画のサーフェスはスクリーンリーダーから認識できないため、Semanticsを明示的に実装します。 CustomPaint を ExcludeSemantics でラップし、兄弟の Semantics ノードで表示値(形状ではなく答え)を読み上げるか、semanticsBuilder から List<CustomPainterSemantics> を返して要素ごとのノードを作成します。色だけを情報伝達の手段にしてはいけません。色だけに頼らないルールは accessibility-as-code が担います。アクセシビリティ操作では、ジェスチャーと同じコマンドを実行します。

  11. ジオメトリは方向に依存させず、ミラーリングするのはクロームだけです。 プロットデータ、物理的なドラッグ、波形には左右の向きがないため、RTL用に反転してはいけません。凡例、ツールチップ、軸の配置、ラベルの開始側はミラーリングします。符号を導出する場合は Directionality.of(context) を使い、ハードコードされた Offset(-x, y)、.left、.right ではなく *Directional インセットを使います。

  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 とテーマで鮮明さを保てます。同一のマークが多数ある場合は、N 個のウィジェットではなく、1 回の Canvas パスで描画します。
  • ファーストパーティのスクワークル: RoundedSuperellipseBorder/ClipRSuperellipse/Canvas.drawRSuperellipse は Impeller の GPU パスを備えたファーストパーティ機能です。figma_squircle/smooth_corner は使わないでください。ContinuousRectangleBorder は iOS 品質のスクワークルではありません。
  • 本物のヘアラインは物理ピクセル 1 つ分です。 strokeAlign: BorderSide.strokeAlignInside を指定し、1.0 / MediaQuery.devicePixelRatioOf(context) を使います。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 で構成されています。ペインターは Notifier/BuildContext/DateTime/ルールを保持しません(ルール 1)。
  • shouldRepaint が返すのは old.scene != scene のみです。フレームごとのアニメーションは shouldRepaint ではなく repaint: を通じて処理します(ルール 2、6)。
  • レイアウトごとに変換を 1 つだけ構築し、ペインターとヒットテスターの両方で読み取ります。toLogical/toCanvas は厳密な逆変換であり、ヒットテスターはスケールを再導出しません(ルール 3)。
  • ヒットテストには整数格子演算、または一度だけラスタライズする Uint32List の領域 ID バッファーを使用します。ホットパスで Path.contains は使用せず、約 44 pt 未満のターゲットは設けません(ルール 4、7)。
  • paint() では、事前に外へ移せるものを割り当てません。Paint/Path/Gradient はフィールドに保持し、要素ごとに .color を変更します。saveLayer を使うのは、実際にグループブレンドが必要な場合に限ります(ルール 5)。
  • アニメーションは repaint: として渡す AnimationController で駆動し、動きは design-system-structure の resolveMotion を通じて解決します(抑制時は最終状態に切り替えます)。色相や動きだけに依存する状態変化はありません(ルール 6)。
  • ジェスチャーハンドラーは localPosition → 変換 → ヒットテスト → 型付きコマンド → ViewModel メソッドの順に処理し、何も変更しません。CustomPaint は HitTestBehavior.opaque の下に配置します(ルール 7)。
  • 動詞ごとに認識器を 1 つ設けます。競合は RawGestureDetector で解決し、軸固定のドラッグはドラッグ開始時の境界内に制限します(ルール 8)。
  • paint() には、正規値から表示値への変換と書式設定を上流で済ませて渡します。大きな履歴は、リビジョンをキーとしてアイソレート外でダウンサンプリングします(ルール 9)。
  • ペインターには ExcludeSemantics を適用し、兄弟要素の Semantics/semanticsBuilder では、色以外の冗長なチャネルとともに表示値を読み上げます。アクセシビリティアクションはジェスチャーコマンドと一致させます(ルール 10)。
  • ペインターに方向を示す符号をハードコードしません。ジオメトリーは方向に依存しないものとし、クロームは 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-composition を参照してください。
  • .select による再ビルド範囲の設定、RepaintBoundary の予算管理、アイソレート外での処理については、flutter-performance を参照してください。
  • View がウィジェット層でスナップショットを取得し、ペインター/Scene のフィールドに渡すテーマカラー、極細線/形状、モーション低減トークンについては、design-system-structure を参照してください。ペインターが BuildContext を読み取ることはありません。また、同スキルが所有する resolveMotion のモーション低減ヘルパーに、このスキルのアニメーション処理を委ねます。
  • この Semantics が従う、色だけに依存しないルール、MediaQuery のアクセシビリティフラグ、冗長なチャネル、44px のターゲット、sortKey のルールについては、accessibility-as-code を参照してください。
  • paint() に表示値を渡す、正規ストアと描画時のローカライズに関する契約については、i18n-rtl-l10n を参照してください。
  • このサーフェスのアニメーションが保証する内容、つまりジェスチャーの確定ごとに 1 回のハプティクス、割り込み可能性、宣言されたモーション低減時の最終状態については、motion-and-haptics を参照してください。
  • 実際のフォントを使ったゴールデンテストで描画済みサーフェスを固定する方法については、widget-golden-and-a11y-testing を参照してください。

参考資料

コメント