widget-composition について
Widget Compositionは、小さく役割を絞ったWidgetクラスで画面を構築するためのFlutter開発スキルです。コンポジション、再ビルド、状態の境界、レイアウト、キー、アクセシビリティの指針と、参考資料およびPR前のチェック用スクリプトを提供します。
主な機能
- 01役割を絞ったWidgetクラスで画面を構成
- 02buildメソッドを簡潔で浅く保つ
- 03ビュー、状態、ドメインロジックを分離
- 04遅延リストと安定したキーの使用
- 05方向対応レイアウトとジェスチャーの代替操作
- 06Widget構成チェック用スクリプトの実行
活用シーン
- 01Flutter画面や共有コンポーネントの設計
- 02Widgetの再ビルドと状態境界の確認
- 03リスト、グリッド、適応型レイアウトの実装
- 04PR前のWidget構成チェック
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill widget-compositionインストール後、Claude Code で/widget-compositionを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/feature_view.dartstructural_grid.dartreferences/rebuild-mechanics.mdstructural-layout.mdwidget-and-data-selection.mdscripts/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 を実行してください。
譲れないルール
Widgetクラスを抽出し、Widgetを返すメソッドは使いません。Widget _buildHeader()はウィジェットではありません。サブツリーには独自のElementがないため、Element.updateChildが新旧を比較して再構築を省略することはできません。親と一緒に再構築され、constにできず、Keyも指定できず、テストでfind.byTypeから参照することもできません。- 可能な場所ではすべて
constを使い、すべてのフィールドをfinalにします。constコンストラクターは同一のウィジェットを同じオブジェクトに正規化するため、Flutterがサブツリーの再構築を省略できます。prefer_const_constructorsとprefer_const_constructors_in_immutablesを有効にします。constを手作業で調整する時間はゼロにします。dart fix --applyに任せ、constにするためにツリーを組み替えることは決してしません。 build()は小さく、浅く保ちます。dart3-idioms-and-coding-standardsで定められた複雑さの上限(build()は80行以下、ウィジェットのビルドのネストは5階層以下。表でウィジェットツリーとロジックに対して示されている、ネストが深い場合の例外は3階層以下)を超えたら、子ウィジェットのクラスを抽出します。スクロールしないと読めないbuild()は大きすぎます。- ウィジェットごとに責務を1つにします。 コンポーネントは1つのものを描画し、データの取得、値のフォーマット、時刻の取得、アプリの状態の保持、副作用のスケジュールを同時に行いません。
build()内でI/O、フォーマット、ドメイン計算を行いません。build()は毎秒60回実行されることがあります。リポジトリやDAOの呼び出し、DateTime.now()、NumberFormat、ソートは行いません。Notifierが描画可能なUI状態の値を事前に計算し、ウィジェットはそれを描画するだけにします。- Viewは何も知らず、ViewModelを1つだけ監視します。
ConsumerWidgetはref.watch(oneNotifierProvider)を呼び出して描画し、インテントはref.read(...notifier)を通じて返します。リポジトリやDAOに直接アクセスしたり、永続化された状態を変更したりすることはありません。 - 基本は
StatelessWidget/ConsumerWidgetにします。StatefulWidget/ConsumerStatefulWidgetは、真にローカルで一時的なUI状態(AnimationController、TextEditingController、ScrollController、FocusNode)に限って使用します。アプリ、ドメイン、セッションの状態はNotifierに保持します。 State内で作成したコントローラーは必ずdispose()します。AnimationController/TextEditingController/ScrollController/FocusNode/サブスクリプションをリークすると、メモリが保持され、破棄後にも処理が発生します。cancel_subscriptions/close_sinksはエラーです。- リストは遅延読み込みにします。 可変長または長いリストには
ListView.builder/SliverListを使用します。データ件数に上限がない場合、ListView(children: items.map(...).toList())は使いません。すべての行が遅延なく一度に構築されます。 - コールバックでは内容ではなく識別情報を解決します。 タップ時のクロージャーでキャプチャするのは安定したID(
onTap: () => onSelect(item.id))とし、キャプチャした内容の値は使いません。編集後に再タップしても、古い値に基づく処理が実行されないようにします。 - すべてのジェスチャーには、見えていてフォーカス可能な代替手段を用意します。 タッチで実行できる動作はすべて、ラベル付きでフォーカス可能なコントロールからも実行できるようにします。ドラッグのみ、または長押しのみで実行できるアクションは、スイッチアクセスやスクリーンリーダーを使うユーザーには利用できません。
- テーマ/トークンと論理方向に基づいてスタイルを設定します。 色やテキストスタイルは
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にピクセルを描画する唯一の例外です。
参考資料
- Flutter — パフォーマンスに関するベストプラクティス(const コンストラクター、再ビルドの最小化、遅延ビルダー)
- Flutter — UI レイヤーのケーススタディ/dumb view
- Flutter API —
StatelessWidget·ListView.builder·EdgeInsetsDirectional·SafeArea - Riverpod — プロバイダーの読み取り/
ConsumerWidget、select - Dart — Effective Dart:スタイル · リンタールール