custom-canvas-and-gestures について
カスタム描画キャンバス向けの Flutter 開発スキルです。View、Painter、Scene の分離を軸に、描画、ジェスチャー、アクセシビリティを整合させる方法を扱います。
主な機能
- 01View・Painter・不変 Scene の分離
- 02描画とヒットテストで共有する変換
- 03形状に応じたヒットテスト方式
- 04paint 内の割り当て回避
- 05ジェスチャーとアクセシビリティの指針
- 06キャンバス向け検査スクリプト
活用シーン
- 01インタラクティブなチャートやキャンバスの設計
- 02ポインター判定と描画位置の整合
- 03カスタム画面へのスクリーンリーダー対応
- 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.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()、2 つの再描画経路に関する落とし穴。references/gestures-and-semantics.md— 翻訳役としてのジェスチャー、ジェスチャーアリーナの調停、衝突ではなくクランプするドラッグ、カスタムキャンバスの Semantics、アクションとジェスチャーの同等性。references/text-and-shapes.md—TextPainterによる計測とフィッティング、視覚的な中央揃え、RoundedSuperellipseBorder、物理ピクセル単位のヘアライン、同心円の半径、画像アセットよりペインターを優先する方法。
PR の前に scripts/check_painter_hygiene.sh と scripts/analyze.sh を実行してください。
必ず守るルール
-
キャンバスサーフェスは View、Painter、イミュータブルな Scene の 3 者で構成します。単一の巨大ウィジェットにはしません。 View は ViewModel を監視し、ペインターに必要なものだけをすべて含むイミュータブルな
Scene値型を構築して、CustomPaintに渡します。Painter extends CustomPainterは余計な判断をしません。Notifier、BuildContext、DateTime、ドメインルールは持たず、Scene を描画するだけで状態を決定しません。余計な判断をしないペインターはテストしやすく、スキンの変更も容易です。 -
shouldRepaintはold.scene != sceneという値の比較だけにします。それ以外は何も行いません。Sceneは値の等価性を持つため、これが正確かつ低コストな方法です。=> trueは毎フレーム再描画し、誤ったfalseはサーフェスを更新不能にします。どちらもエラーなく問題を引き起こします。設定による再描画にはshouldRepaintを使い、フレームごとのアニメーションにはrepaint:のListenableを使います。この 2 つの経路で再描画を二重に起こしてはいけません。 -
論理空間とキャンバスのピクセル間の変換には、アフィン変換を 1 つだけ使い、ペインターとヒットテスターの両方から参照します。 入力された
Sizeからレイアウトごとに一度だけ変換を作成し、toCanvas/toLogicalを厳密な逆変換として公開します。最も深刻な不具合は、ペインターとヒットテスターの座標が数ピクセルずれることです。変換を共有してテストすれば、これを防げます。ヒットテスターはsizeからスケールを再計算せず、transform.toLogicalを呼び出します。 -
ヒットテストはジオメトリクラスに応じて行います。格子には整数、複雑な領域にはラスタライズ済み ID バッファを使い、ホットパスでは
Path.containsを使いません。 グリッドセルは(x ~/ cell, y ~/ cell)で求め、軸ごとに 1 回だけ除算します。複雑な領域は、cell → RegionIdのUint32Listをインデックスとして使い、ロード時に一度だけラスタライズします。これなら O(1) で、凹形にも対応できます。タップのたびに N 個の凹多角形と照合する方法は適切ではありません。 -
paint()の中では何も割り当てません。 すべてのPaint、Path、Gradientをペインターのフィールドとして事前に用意し、要素ごとに.colorを変更して使います。paint()内でPaint()/Path()を作成するのは、カクつきの典型的な原因です。saveLayerは実際にグループ不透明度やブレンドが必要な場合に限って使い、単純な塗りつぶしには使いません。 -
すべてのアニメーションは、ペインターの
repaint:Listenableとして渡すAnimationControllerで駆動します。ティッカー内でsetState/notifyListenersを呼び出してはいけません。 これによりウィジェットツリーを再構築せず、ペインターを直接再描画できます。アニメーションの継続時間は、design-system-structureの視差低減トークン/resolveMotionヘルパーを使って解決します(このルールはそこで管理されています。より穏やかな動きにするのではなく、ゼロにします)。動きが抑制されている場合は、トゥイーンせずに終了状態へ直接移行します。状態の変更を、動きや色相だけで知らせてはいけません。accessibility-as-codeに従い、色以外の伝達手段(形状+ラベル+触覚フィードバック)も併用します。 -
ジェスチャーハンドラーは純粋な変換器です。ポインター →
localPosition→ 共有トランスフォーム → ヒットテスト → 型付き不変コマンド → ViewModelメソッド、という流れにします。状態は一切変更しません。TapDownDetails.localPosition/DragUpdateDetails.localPositionを読み取り(globalPositionは決して使いません)、transform.toLogicalで変換して、ref.read(vmProvider.notifier).<command>(...)を呼び出します。透明な隙間も含めて矩形全体で操作できるよう、CustomPaintをHitTestBehavior.opaqueでラップします。 -
動詞1つにつき認識器1つ。アリーナでの競合は明示的に調停します。 主な動詞には認識器を1つだけ割り当て、副次的な動詞にはそれぞれ別の認識器を割り当てます。同じポインターを2つが認識できる場合は、期待に頼らず
RawGestureDetectorで解決します。軸ロックされたドラッグは、ドラッグ開始時の境界を固定するクランプであり、フレームごとの衝突検出ではありません。 -
正規の値を入力し、表示用の値を出力します。
paint()内で単位変換、日付の書式設定、数字の形の調整をしてはいけません。 ペインターには、あらかじめ表示単位に変換され、上流で書式設定された値を渡し、ペインターは数値をピクセルに対応付けます。大量の履歴データは、リビジョンカウンターをキーとしてIsolate.runを使い、アイソレート外でバケット化/ダウンサンプリングします。paint()内で数千個の点をループしてはいけません。 -
独自描画のサーフェスはスクリーンリーダーから認識できないため、Semanticsを明示的に実装します。
CustomPaintをExcludeSemanticsでラップし、兄弟のSemanticsノードで表示値(形状ではなく答え)を読み上げるか、semanticsBuilderからList<CustomPainterSemantics>を返して要素ごとのノードを作成します。色だけを情報伝達の手段にしてはいけません。色だけに頼らないルールはaccessibility-as-codeが担います。アクセシビリティ操作では、ジェスチャーと同じコマンドを実行します。 -
ジオメトリは方向に依存させず、ミラーリングするのはクロームだけです。 プロットデータ、物理的なドラッグ、波形には左右の向きがないため、RTL用に反転してはいけません。凡例、ツールチップ、軸の配置、ラベルの開始側はミラーリングします。符号を導出する場合は
Directionality.of(context)を使い、ハードコードされたOffset(-x, y)、.left、.rightではなく*Directionalインセットを使います。 -
サーフェスを
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/AsyncNotifierViewModel と、ジェスチャーが呼び出すコマンドについては、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を参照してください。
参考資料
- 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