はじめに
FlutterでUIを作っていると、同じような見た目の部品に出会うことは少なくありません。
ここでいう「Widget」は、FlutterにおけるUIの最小単位です。
たとえば、ボタン・テキスト・レイアウト構造そのものがすべてWidgetとして表現されます。
つまり、Flutterの設計を考えるうえで、Widgetの切り分け方は非常に重要なテーマです。
たとえば、ボタン、カード、フォームの入力欄、ローディング表示などです。
このとき、多くの開発者は「同じUIがあるから、まとめてしまおう」と考えます。
しかし、ここで重要なのは、見た目が似ていることと、再利用可能なUIコンポーネントとして設計できることは別物だという点です。
実際には、Flutterでは「ただの共通化」と「再利用できるWidget」を明確に分けることが、保守性・可読性・パフォーマンスの両立に直結します。
この記事では、次の3つを軸に、設計の判断基準を整理します。
- 関数で切り出すことと、Widgetクラスとして切り出すことの違い
- 画面固有の部品と、アプリ全体で使われるUIコンポーネントの境界線
- ThemeやButtonStyleと、独自Widgetの使い分け
1. まず結論:関数で切り出すのは「共通化」ではあっても「Widget設計」ではない
Flutterでは、Widgetを切り出す方法として主に2つあります。
- メソッドや関数として切り出す
-
StatelessWidgetやStatefulWidgetとして切り出す
どちらも見た目上は同じように見えます。
しかし、Flutterの内部では挙動がまったく違います。
関数として切り出したものは、FlutterのWidgetツリー上では独立したノードにはなりません。
そのため、親Widgetが再ビルドされるたびに、関数の中身は再評価されます。
一方で、Widgetクラスとして切り出すと、Flutterがその部品を独立したWidgetとして管理できるようになります。
これにより、constを活用した最適化や、再ビルド範囲の制御がしやすくなります。
つまり、関数で切り出すこと自体は間違いではありません。
ただし、「再利用を前提にしたUI部品」として扱うなら、クラスとして切り出す方が自然です。
2. 典型的な誤解:見た目が同じだから、すぐに共通化する
よくある失敗パターンは、次の流れです。
- 同じUIを見つけた
- すぐにメソッドに切り出した
- 引数を増やしていった
- 条件分岐が増えた
- 最後に誰も触りたくない「モンスターWidget」になった
これは単にコードをまとめた結果ではなく、UIの責務を整理できていない状態です。
本質的には、次の2つを分ける必要があります。
- 画面固有のUIをまとめるもの
- アプリ全体で再利用するUI部品
前者は「切り出し」で十分です。
後者は「再利用できるWidget」として設計するべきです。
3. 関数切り出しの落とし穴
まずは、よくある関数切り出しの例です。
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: _buildPrimaryButton(
label: 'ログイン',
onTap: () {},
),
),
);
}
Widget _buildPrimaryButton({
required String label,
required VoidCallback onTap,
}) {
return ElevatedButton(
onPressed: onTap,
child: Text(label),
);
}
}
この書き方は、見た目上は「共通化」されています。
しかし、Flutterの観点では、それはまだ「再利用可能な部品」としての設計とは言い切れません。
何が起きやすいか
- 親Widgetが再ビルドされるたびに、内部のUIが再評価される
-
BuildContextを扱う場面で、古いコンテキストを参照してしまうリスクがある -
AnimatedSwitcherや差分更新のような場面では、Widgetの識別が曖昧になりやすい
このため、単純な可読性向上のために関数を使うのは悪いことではありません。
ただし、再利用性や安定性を重視するなら、クラスとして切り出す方が安全です。
4. では、どんなときにWidgetクラスにするべきか
再利用を前提にしたUI部品は、基本的に StatelessWidget や StatefulWidget として切り出すのが自然です。
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onTap,
});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onTap,
child: Text(label),
);
}
}
これが良い理由
- FlutterがWidgetとして認識し、Widgetツリー上で管理しやすい
-
const最適化を活かしやすい - 親Widgetの再ビルドに対して、部品単位で考えやすい
- 実装の責務が明確になり、保守しやすい
この考え方を持っておくと、「UI部品をどこまで切り出すべきか」を判断しやすくなります。
5. 「ただの共通化」と「再利用できるWidget」の境界線
中級者が一番迷うのは、この境界です。
結論から言うと、次のように見ると整理しやすいです。
| 観点 | 単なる共通化 | 再利用できるWidget |
|---|---|---|
| 目的 | 画面内のコードを減らす | アプリ全体で使い回す |
| 依存 | 画面特有のデータ構造に依存しやすい | プリミティブな値や汎用的な引数に依存する |
| 配置 | 画面ごとのフォルダ | 共通UIフォルダ |
| 変更頻度 | その画面に限定される | 複数画面で使われる |
| 例 | 投稿カード、プロフィール編集フォーム | ボタン、入力欄、セクションタイトル |
1つ目の判断基準:そのUIが他の画面でも使われるか
もしそのUIが「この画面だけ」で完結するなら、画面内のWidgetとして切り出すだけで十分です。
class PostCard extends StatelessWidget {
const PostCard({super.key, required this.post});
final Post post;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(post.title),
subtitle: Text(post.author),
),
);
}
}
これは「投稿一覧画面専用のUI部品」です。
そのため、画面内の責務として切り出すのは自然です。
2つ目の判断基準:そのUIがドメインに依存していないか
逆に、PostやUserなどの具体的なモデルに依存しているなら、それは画面に紐づく部品かもしれません。
一方で、次のような部品は再利用性が高いです。
PrimaryButtonAppTextFieldSectionHeaderLoadingStateView
これらは、ドメインの詳細を知らなくても組み合わせられる汎用部品です。
6. ThemeとButtonStyleで済むのか、独自Widgetが必要なのか
よくある疑問がこれです。
「ボタンの見た目が共通なら、ElevatedButtonをラップしてWidgetにする必要があるのか?」
この問いに対しては、次のように考えると整理しやすいです。
見た目の統一だけならThemeやButtonStyleで十分
ボタンの色・角丸・余白など、見た目の規約だけを統一したいなら、ThemeDataやButtonStyleで管理するのが自然です。
ThemeData(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
これは「アプリ全体の見た目の規約」を決めるための仕組みです。
ただし、振る舞いまで含めるなら独自Widgetにする
一方で、次のような要件があるなら独自Widgetにすべきです。
- ローディング状態を持つ
- アイコンの位置や文言が変わる
- 非同期処理に対応する
- 表示内容と振る舞いが一体で管理される
class AppPrimaryButton extends StatelessWidget {
const AppPrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.loading = false,
});
final String label;
final VoidCallback? onPressed;
final bool loading;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: loading ? null : onPressed,
child: loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(label),
);
}
}
見た目の共通化はTheme、振る舞いまで含めた再利用はWidgetという分け方が、かなり実務的です。
7. 実務での判断基準
迷ったときは、次の4つで判断するとかなり整理しやすいです。
- そのUIが1画面に閉じるなら、画面内Widgetで十分
- 複数画面で使うなら、共通Widgetとして切り出す
- 見た目の統一だけならThemeで管理する
- 振る舞いまで含めるなら独自Widgetにする
この基準を持っておくと、コードを「ただまとめる」状態から、設計としての抽象化へ移せます。
8. まとめ
FlutterでUIを設計するとき、重要なのは「同じ見た目だからまとめる」ことではなく、次の3点を明確にすることです。
- どのレベルで抽象化するのか
- その部品がどこまで再利用されるのか
- 見た目なのか、振る舞いまで含めるのか
関数で切り出すのは、画面内の小さなUI整理には向いています。
一方で、再利用を前提にした部品として設計するなら、StatelessWidget や StatefulWidget として切り出す方が安定します。
そして、見た目の統一はTheme、振る舞いまで含めた再利用はWidgetという分け方を意識すると、かなり設計が整理されます。
FlutterのUI設計は、単なる「コードの切り出し」ではなく、責務の境界を決める作業です。
その境界を意識できるようになると、コードはずっと扱いやすくなります。
9. 番外編:AIに設計判断を任せるときのコツ
最近では、AIを使ってWidgetの切り分け方や共通化の方針を相談することも増えています。
ただし、AIに「このUIを共通化してください」とだけ頼むと、期待とは違う設計が返ってくることがあります。
特に、AIは次のような点で迷いやすいです。
- 画面固有のUIと、共通UIを区別しにくい
- どこまでをThemeで管理すべきかを判断しにくい
- 使い回しを前提にしたWidgetなのか、単なる切り出しなのかを見落としやすい
そのため、AIに依頼するときは、次のように条件を添えるとかなり精度が上がります。
- 「このWidgetは複数画面で使う前提か」
- 「見た目だけの統一なのか、振る舞いも含めるのか」
- 「ドメインに依存するUIなのか、汎用UIなのか」
- 「Themeで管理すべき部分と、Widgetとして切り出すべき部分を分けて考えてほしい」
たとえば、次のような依頼が効果的です。
このUIをFlutterで実装したいです。
ただし、これは複数画面で再利用する汎用コンポーネントか、画面固有の部品かを判断しながら設計してください。
また、見た目の統一はThemeで管理すべきか、Widgetとして切り出すべきかも含めて説明してください。
AIはあくまで「設計の候補」を出してくれる存在です。
最終的な判断は、開発者が「そのUIがどの責務を持つべきか」を見極める必要があります。
つまり、AIに設計を任せる前に、まずは「この部品は何を責務として持つべきか」を自分で整理しておくことが重要です。
そうしておくと、AIの提案もかなり自然に扱いやすくなります。