mcpField
W
widget-composition

widget-composition

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

widget-composition について

Widget Compositionは、小さく役割を絞ったWidgetクラスで画面を構築するためのFlutter開発スキルです。コンポジション、再ビルド、状態の境界、レイアウト、キー、アクセシビリティの指針と、参考資料およびPR前のチェック用スクリプトを提供します。

主な機能

  1. 01役割を絞ったWidgetクラスで画面を構成
  2. 02buildメソッドを簡潔で浅く保つ
  3. 03ビュー、状態、ドメインロジックを分離
  4. 04遅延リストと安定したキーの使用
  5. 05方向対応レイアウトとジェスチャーの代替操作
  6. 06Widget構成チェック用スクリプトの実行

活用シーン

  1. 01Flutter画面や共有コンポーネントの設計
  2. 02Widgetの再ビルドと状態境界の確認
  3. 03リスト、グリッド、適応型レイアウトの実装
  4. 04PR前のWidget構成チェック

インストール

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

$npx skills add https://github.com/zakariaf/flutter-skills --skill widget-composition

インストール後、Claude Code で/widget-compositionを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

examples/
feature_view.dart
structural_grid.dart
references/
rebuild-mechanics.md
structural-layout.md
widget-and-data-selection.md
scripts/
check-widget-composition.sh

詳細

ウィジェットの構成

画面はすべて、小さく、const で、単一の目的を持つウィジェットクラスを組み合わせて構築します。1つのことだけを行うウィジェットは、再ビルドのコストが低く、コードが読みやすく、単体でテストでき、共有コンポーネントライブラリに切り出せます。View は受動的です。1つの ViewModel を監視して描画し、ロジックは Notifier に、状態は不変の値オブジェクトに、レイアウトは方向性を考慮した構造プリミティブに置きます。画面、リスト、カード、共有コンポーネントのいずれにも適用できます。

作業内容に応じて、以下のリファレンスを確認してください。

  • references/rebuild-mechanics.md — メソッドよりクラスが優れている理由(Element.updateChild)、const の正準化、キーの完全なポリシー、再ビルド範囲を絞る .select。
  • references/structural-layout.md — 画面端まで広がる背景と SafeArea 内のコンテンツ、計算によるセルサイズ、GridView の交差軸/主軸の落とし穴、方向性を考慮したインセット、IME と resizeToAvoidBottomInset。
  • references/widget-and-data-selection.md — 最も低コストなウィジェットの一覧、データクラスの選択表(drift の行 vs レコード vs 手書き vs freezed)、ジェスチャーからフォールバックへのルール。

PR を作成する前に scripts/check-widget-composition.sh を実行してください。

譲れないルール

  1. Widgetクラスを抽出し、Widgetを返すメソッドは使いません。 Widget _buildHeader()はウィジェットではありません。サブツリーには独自のElementがないため、Element.updateChildが新旧を比較して再構築を省略することはできません。親と一緒に再構築され、constにできず、Keyも指定できず、テストでfind.byTypeから参照することもできません。
  2. 可能な場所ではすべてconstを使い、すべてのフィールドをfinalにします。 constコンストラクターは同一のウィジェットを同じオブジェクトに正規化するため、Flutterがサブツリーの再構築を省略できます。prefer_const_constructorsとprefer_const_constructors_in_immutablesを有効にします。constを手作業で調整する時間はゼロにします。dart fix --applyに任せ、constにするためにツリーを組み替えることは決してしません。
  3. build()は小さく、浅く保ちます。 dart3-idioms-and-coding-standardsで定められた複雑さの上限(build()は80行以下、ウィジェットのビルドのネストは5階層以下。表でウィジェットツリーとロジックに対して示されている、ネストが深い場合の例外は3階層以下)を超えたら、子ウィジェットのクラスを抽出します。スクロールしないと読めないbuild()は大きすぎます。
  4. ウィジェットごとに責務を1つにします。 コンポーネントは1つのものを描画し、データの取得、値のフォーマット、時刻の取得、アプリの状態の保持、副作用のスケジュールを同時に行いません。
  5. build()内でI/O、フォーマット、ドメイン計算を行いません。 build()は毎秒60回実行されることがあります。リポジトリやDAOの呼び出し、DateTime.now()、NumberFormat、ソートは行いません。Notifierが描画可能なUI状態の値を事前に計算し、ウィジェットはそれを描画するだけにします。
  6. Viewは何も知らず、ViewModelを1つだけ監視します。 ConsumerWidgetはref.watch(oneNotifierProvider)を呼び出して描画し、インテントはref.read(...notifier)を通じて返します。リポジトリやDAOに直接アクセスしたり、永続化された状態を変更したりすることはありません。
  7. 基本はStatelessWidget/ConsumerWidgetにします。 StatefulWidget/ConsumerStatefulWidgetは、真にローカルで一時的なUI状態(AnimationController、TextEditingController、ScrollController、FocusNode)に限って使用します。アプリ、ドメイン、セッションの状態はNotifierに保持します。
  8. State内で作成したコントローラーは必ずdispose()します。 AnimationController/TextEditingController/ScrollController/FocusNode/サブスクリプションをリークすると、メモリが保持され、破棄後にも処理が発生します。cancel_subscriptions/close_sinksはエラーです。
  9. リストは遅延読み込みにします。 可変長または長いリストにはListView.builder/SliverListを使用します。データ件数に上限がない場合、ListView(children: items.map(...).toList())は使いません。すべての行が遅延なく一度に構築されます。
  10. コールバックでは内容ではなく識別情報を解決します。 タップ時のクロージャーでキャプチャするのは安定したID(onTap: () => onSelect(item.id))とし、キャプチャした内容の値は使いません。編集後に再タップしても、古い値に基づく処理が実行されないようにします。
  11. すべてのジェスチャーには、見えていてフォーカス可能な代替手段を用意します。 タッチで実行できる動作はすべて、ラベル付きでフォーカス可能なコントロールからも実行できるようにします。ドラッグのみ、または長押しのみで実行できるアクションは、スイッチアクセスやスクリーンリーダーを使うユーザーには利用できません。
  12. テーマ/トークンと論理方向に基づいてスタイルを設定します。 色やテキストスタイルはTheme.of(context)またはThemeExtensionから取得し、16進数の値を直接指定しません。左右をハードコードせず、EdgeInsetsDirectional/AlignmentDirectional(start/end)を使い、RTLで自動的に反転するようにします。

ウィジェットとメソッドのどちらを抽出するか

// WRONG — helper method: rebuilds with parent, no const, no key, no isolation, untestable
Widget _buildProductCard(Product product) {
  return Card(child: Text(product.name));
}

// RIGHT — widget class: const-constructible, keyable, independently rebuildable, testable
class ProductCard extends StatelessWidget {
  const ProductCard({required this.name, required this.price, this.onTap, super.key});

  final String name;          // already-localized; the feature mapped Product -> name
  final String price;         // already-formatted; no currency math here
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context); // read theme in the leaf that uses it
    return Card(
      child: InkWell(
        onTap: onTap,
        child: Padding(
          padding: const EdgeInsetsDirectional.all(12), // logical, RTL-safe
          child: Row(
            children: [
              Expanded(child: Text(name, style: theme.textTheme.titleMedium)),
              Text(price, style: theme.textTheme.bodyMedium),
            ],
          ),
        ),
      ),
    );
  }
}

build()を簡潔に保つ — Notifierが事前に計算する

// WRONG — domain math + formatting inside build()
@override
Widget build(BuildContext context, WidgetRef ref) {
  final order = ref.read(orderRepositoryProvider).find(id); // repo call in build!
  final remaining = order.total - order.paid;               // domain math in UI
  final label = NumberFormat.currency().format(remaining);  // formatting in UI
  return Text(remaining <= 0 ? 'Done' : label);
}

// RIGHT — the Notifier exposes ready-to-render UI state; select narrows the rebuild
@override
Widget build(BuildContext context, WidgetRef ref) {
  final status = ref.watch(orderNotifierProvider(id).select((s) => s.balanceStatus));
  return Text(status.label, style: status.isSettled ? _settledStyle : _dueStyle);
}

ref.watch(p.select((s) => s.field))を使うと、そのフィールドが変化したときだけ再構築されます。これはRiverpodにおけるリーフのconstに相当します。監視/読み取り/リッスンの使い分けについてはstate-management-riverpodを、.selectとconstサブツリーの適用範囲についてはflutter-performanceを参照してください。

構成の指針

  • 画面をセクション(ヘッダー、本文、フッター)に分け、それぞれを別のウィジェットクラスにします。 行は、再利用される部分や複雑な部分があれば分割します。
  • データは下に渡し、コールバックは上に返します。 コンポーネントは表示するデータを受け取り、onX コールバックを公開します。親や、自身が所有していないプロバイダーにはアクセスしません。
  • MediaQuery/Theme は必要とする末端のウィジェットで読み取ります。 大きな build() の先頭で読み取ると、メトリクスが変わるたびにサブツリー全体が再ビルドされます。
  • 最も軽量なウィジェットを優先します。 余白には Container より SizedBox、単一の効果には Container より ColoredBox/DecoratedBox、Row+Spacer より Align/Center を使います。references/widget-and-data-selection.md を参照してください。
  • 変化しないサブツリーは const のローカル変数に切り出し、再ビルド後も維持できるようにします。
  • 共有コンポーネントはドメインに依存せず、プロバイダーも使いません。 コンストラクターを通じて、プリミティブ値、ローカライズ済みラベル、不変のビューデータ、コールバックを受け取ります。ドメインエンティティ、リポジトリ、ref は受け取りません。依存関係の向きは一方向です。フィーチャーは共有ライブラリをインポートしますが、その逆はありません。

キー

キーが重要になるのは、子リスト内の同じ位置にある新しい Widget に対して、フレームワークが Element(およびその State)を再利用できるかどうかを判断する必要がある場合だけです。よくある不具合は、リストの並べ替え後、再利用されたスロットに古い State が残ってしまうことです。

状況キー
並べ替え可能/フィルター可能/可変長のリスト内の行ValueKey(item.id) — 移動しても安定した識別子が維持されます
順序と長さが固定されたリスト、葉となるステートレスウィジェットなし — キーを付けても何も変わりません
同じ型のウィジェットを入れ替え、状態を維持する必要がある場合ウィジェットを区別する ValueKey
ウィジェットが突然 GlobalKey を「必要とする」場合いったん止めてください — それは修正方法ではなく、設計上の問題です

識別や検索のために GlobalKey を追加しないでください。グローバルレジストリーの検索を強制し、ツリー間の再親子化を可能にしますが、ほとんどのアプリで必要な仕組みではありません。仕組みの詳細は references/rebuild-mechanics.md を参照してください。

ビューデータ用のデータクラス

ジェネレーターがすでに生成するデータクラスを、手作業で実装しないでください。判断基準は次のとおりです。

形状使用するもの
永続化された行drift が生成する行クラスをそのまま使用します。すでに ==、hashCode、copyWith が備わっています
同じレイヤー内で一時的に複数の値を返す場合レコード — (int row, int col)。レイヤーの境界を越えることはありません
手書きのドメイン値/UI 状態単純な値なら @immutable+const+final、複雑な値型、ユニオン、または copyWith が頻繁に必要な場合は freezed を使用します。判断基準は dart3-idioms-and-coding-standards が定めます
ジェネレーターが生成しない結合形状手書きの @immutable クラス

== のためだけに equatable を追加しないでください。Object.hash(...) は5行で書けます。表全体とその理由は references/widget-and-data-selection.md を参照してください。値型とレコードの規則については dart3-idioms-and-coding-standards を参照してください。

構造的なレイアウト

ウィンドウは端から端まで広がりますが、操作対象は内側に配置します。この2つは別のことです。システムバーの背後まで背景を全面に描画し、そのうえで操作可能なコンテンツを SafeArea で囲み、内側にマージンを追加します。

Scaffold(
  backgroundColor: Theme.of(context).colorScheme.surface, // full-bleed, behind bars
  resizeToAvoidBottomInset: false, // a fixed grid must not reflow under the IME
  body: SafeArea(
    child: Padding(
      padding: const EdgeInsetsDirectional.all(16), // logical inset, RTL-safe
      child: child,
    ),
  ),
)

ビューポートからセルサイズを計算します。タイルの寸法をハードコードしたり、最小サイズを強制したりしないでください(サイズの下限を設けると、大きな文字に最も広いスペースが必要な場面で、まさにその制約が発動してしまいます。実際の制約は、ラベルが収まることです)。GridView の軸に関する落とし穴は、気づきにくいものです。縦方向のグリッドでは、crossAxisSpacing は列間の間隔、mainAxisSpacing は行間の間隔です。

GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    crossAxisSpacing: 12, // COLUMN gap in a vertical grid
    mainAxisSpacing: 16,  // ROW gap
  ),
  itemCount: items.length,
  itemBuilder: (context, i) => ProductCard(name: items[i].name, price: items[i].price),
);

計算によるサイズ決定の数式、IME の動作、MediaQuery.viewInsets、またセルを複数列にまたがらせる代わりに Column+グリッドを使う場面など、詳しい内容は references/structural-layout.md を参照してください。MediaQuery からアクセシビリティ状態を読み取り、textScaler に上限を設定しない方法については accessibility-as-code を、トークン/テーマの構成については design-system-structure を参照してください。

アンチパターン

  • 重要な UI に対する Widget _buildXxx() ヘルパーメソッド — Element 境界がなく、const にできず、キーも設定できず、テストもできません。クラスにしてください。
  • ゴッドウィジェット:画面全体をインラインで記述した 300 行の build()。
  • build() または initState() 内での Repository/DAO/SharedPreferences の呼び出し — Notifier を経由し、書き込み経路を 1 つにしてください。
  • ウィジェット内の DateTime.now() — ウィジェットからシステム時刻を読み取ってはいけません。Notifier が clockProvider 経由で注入された Clock から時間に基づく値を事前計算します(state-management-riverpod を参照)。
  • State 内で作成して dispose() する代わりに、build() 内でコントローラーを作成すること(毎フレーム再作成されます)。
  • Notifier に属するアプリ/セッションデータの更新に setState() を使うこと — setState はローカルな一時的 UI 状態にのみ使用します。
  • ListView(children: hugeList.map(...).toList()) — すべての行を先行して構築します。.builder を使用してください。
  • あらゆる用途に Container を使うこと(過剰なラップ) — 用途に合った、より軽量なウィジェットを使用してください。
  • 識別に GlobalKey/ObjectKey を使うこと — 削除してください。リストの並べ替えには ValueKey(id) を使用します。
  • 16 進数カラーのハードコード/左右のパディング指定/ユーザー向け文字列の直書き — テーマトークン、EdgeInsetsDirectional、ARB によるローカライズを使用してください。
  • 視覚的な代替手段のないジェスチャー、またはレイアウトを収めるために textScaler をクランプすること — スケールではなくレイアウトを修正してください。
  • 共有ライブラリのウィジェットがドメイン型をインポートしたり、プロバイダーを読み取ったりすること — そのウィジェットは単独で推論・テストできなくなります。

完了の定義

  • 再利用可能なサブツリーおよび重要なサブツリーはすべて Widget クラス(_buildX() メソッドではない)になっており、可能な箇所は const、すべてのフィールドは final です。
  • build() は約 80 行以下で、ネストは浅く、複雑な部分は切り出されています。
  • build() 内に I/O、Repository 呼び出し、DateTime.now()、フォーマット処理、ドメイン計算がありません — Notifier が UI の状態を事前計算します。
  • View は 1 つの Notifier を監視する ConsumerWidget です。インテントは ref.read(...notifier) 経由でルーティングされ、ウィジェット内で永続化状態を変更しません。
  • StatefulWidget はローカルな一時的状態にのみ使用し、作成したすべてのコントローラーを dispose() します。
  • 長いリストや可変長のリストには .builder を使用し、並べ替え可能なリストには ValueKey(id) を設定し、識別に GlobalKey を使用しません。
  • コールバックでは、キャプチャしたコンテンツ値ではなく、安定した ID を解決します。
  • 共有ウィジェットが受け取るのはプリミティブ値とコールバックのみで、ドメイン型もプロバイダーもインポートしません。
  • 背景は画面全体に広げ、コンテンツは SafeArea 内に配置し、インセットには方向対応の値を使用します。GridView の間隔の軸が正しく、セルサイズをハードコードしたり下限を設けたりしていません。
  • スタイルにはテーマ/トークンを使用し、すべてのジェスチャーに視覚的でフォーカス可能な代替手段があり、textScaler はクランプされていません。
  • dart format のチェックに合格し、dart analyze --fatal-infos のチェックに合格し、scripts/check-widget-composition.sh が成功します。

関連スキル

  • flutter-architecture — このウィジェットが属するレイヤー DAG と dumb-View コントラクトです。widget-composition では、これらの View の構築方法を詳しく解説します。
  • flutter-performance — const サブツリー、.select による再ビルド範囲の限定、RepaintBoundary、適切な dispose、プロファイルモードでの計測です。
  • state-management-riverpod — この View が監視する、機能ごとに 1 つの Notifier を持つ ViewModel と、watch/read/listen の使い分けです。
  • design-system-structure — トークン、テーマ、コンポーネントの構造と利用方法(このスキルが参照するスタイルの出所)です。
  • accessibility-as-code — MediaQuery から a11y の状態を読み取る方法、Semantics のロール/ラベル、ソートキー、textScaler を決してクランプしないことです。
  • i18n-rtl-l10n — このウィジェットが表示する文字列やインセットが従うべき、方向性を考慮したジオメトリ(EdgeInsetsDirectional/AlignmentDirectional)と ARB ローカライズのルールを扱います。
  • dart3-idioms-and-coding-standards — これらのルールが参照する複雑度制限の表、各データクラスが採用するコンストラクト、レイヤー内に限定されるレコード、不変の値型です。
  • adaptive-layout — レスポンシブブレークポイント、タブレット/フォルダブル向けのマスター・詳細レイアウト、幅に応じた NavigationRail と BottomNav の使い分けです(これらの構造的プリミティブを取り巻くレスポンシブ対応レイヤーです)。
  • forms-and-input — これらの画面で構成する入力フィールドの、Form/TextFormField のバリデーション、フォーカス移動、キーボードアクションの連携です。
  • widget-golden-and-a11y-testing — 抽出したウィジェットクラスを pump し、デバイスやスケールを変えてレイアウト/オーバーフロー/ゴールデンを検証する方法です。
  • custom-canvas-and-gestures — ウィジェットツリーではなく CustomPainter にピクセルを描画する唯一の例外です。

参考資料

コメント