FlutterのTabBarとBottomNavigationBarで複数画面を切り替える の次に、画面を作り足し始めると早めに出てくるのが「lib/main.dart が長くなって、どこを直せばよいか追いにくい」という詰まりどころです。ここで止まりやすいのは StatelessWidget という名前ではなく、「どの単位で分けると読みやすくなるか」が見えにくい点にあります。この記事では、1 つの画面を StatelessWidget へ分割する流れ、親から子へ値を渡す方法、分割タイミングの判断基準を最小構成で整理します。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、基本レイアウト、一覧 UI、確認 UI、タブ切り替えの基本までは終わった人
- 1 画面の UI をそのまま
buildへ書き続けていて、どこで切り出すべきか迷いやすい人 StatelessWidgetを見たことはあるが、自分で部品を作る場面がまだ曖昧な人
この記事で到達する状態
- 見た目のまとまりごとに
StatelessWidgetを切り出せる finalフィールドとコンストラクタ引数で親から子へ値を渡せる- ボタン押下などの処理を親から callback で渡せる
- 「いつ分割するか」を 3 つの観点で判断できる
非対象
StatefulWidgetのライフサイクル詳細- Riverpod などの状態管理
- 複数ファイルへの配置設計
- 高度なパフォーマンス最適化
今回の主題は、状態管理ではありません。まずは 1 画面の中で、読みやすい部品名を付けながら UI を分けるところまでが対象です。ここが整理できると、次に REST API 通信やフォームへ進んだときも、画面構成と処理の置き場を分けやすくなります。
2. まずは分割の目的を 3 つに絞る
カスタムウィジェットへ分割する目的は、次の 3 つです。
| 目的 | 何が良くなるか | 典型例 |
|---|---|---|
| 可読性 | 親の build で画面構成だけ追える | ヘッダー、カード一覧、操作ボタン群 |
| 再利用 | 引数違いで同じ見た目を使い回せる | ステータスカード、一覧行、ラベル |
| 責務分離 | 表示と処理の置き場を分けやすい | 見た目は子、押下時の処理は親 |
分割は「行数を減らすため」だけではありません。名前を付けた部品として切り出し、親の build を「どんな画面か読む場所」に変えるためです。
判断の流れを先に置くと、次の通りです。
flowchart TD
A[このUIはどこで区切るか] --> B{見た目のまとまりがあるか}
B -->|はい| C[Widget名を付けて切り出す]
B -->|いいえ| D{同じ形が2回以上出るか}
D -->|はい| C
D -->|いいえ| E{引数違いで再利用できるか}
E -->|はい| C
E -->|いいえ| F[親のbuildに残す]
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。
3. まずは 1 つの build に詰め込んだ状態を見る
分割の必要性は、長いコードを見たほうが分かりやすくなります。次の lib/main.dart は動きますが、どこからどこまでが 1 つのまとまりか読み取りにくい状態です。
このファイルは、出荷概要と優先タスクを 1 つの build に詰め込んだ状態を再現するサンプルです。注目点は、画面の骨格、重複した UI、押下処理が同じ階層へ混ざっていることにあります。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Shipment Dashboard')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFE8F3FF),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'今日の出荷概要',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text('締切まで 2 時間。未処理の伝票を先に確認します。'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('未処理'),
SizedBox(height: 4),
Text(
'12 件',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('要確認'),
SizedBox(height: 4),
Text(
'3 件',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFD6DCE5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'優先タスク',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const CircleAvatar(child: Text('1')),
title: const Text('出荷待ち伝票を確認する'),
subtitle: const Text('未処理 12 件'),
trailing: FilledButton(
onPressed: () {},
child: const Text('開く'),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const CircleAvatar(child: Text('2')),
title: const Text('保留理由を確認する'),
subtitle: const Text('要確認 3 件'),
trailing: OutlinedButton(
onPressed: () {},
child: const Text('確認'),
),
),
],
),
),
],
),
),
);
}
}
コードのポイント
① 画面の骨格と装飾が親の build に混ざっている
return MaterialApp(
home: Scaffold(
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
親の build で一覧の骨格を読みたい場面なのに、最初の ListView に入った瞬間から装飾用の Container と細かな余白指定が続きます。どの塊が「出荷概要」で、どの塊が「優先タスク」かを名前で追えないため、画面構成より先に装飾コードへ視線が取られます。
② 同じ見た目のカードがその場で重複している
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('未処理'),
SizedBox(height: 4),
Text('12 件'),
],
),
),
),
Expanded(
child: Container(
未処理 と 要確認 のカードは、ラベルと値以外の構造がほぼ同じです。この段階では動作に問題はありませんが、見た目の共通部分を名前付き Widget にしていないため、修正箇所が増えるほど差分を追いにくくなります。
タスク行の見た目と押下処理まで親の build に混ざっているため、画面がもう少し増えると「画面構成」を読む前に細かい装飾コードへ目が止まります。ここで、見た目のまとまりごとに名前付き Widget へ切り出す価値が出てきます。
## 4. 見た目のまとまりごとに `StatelessWidget` へ切り出す
次は、同じ画面を ShipmentSummarySection MetricCard TaskPanel に分けます。親の build が「画面全体の構成だけ読める状態」になることが目的です。
lib/main.dart は次の内容で作成します。
このファイルは、1 つの長い build を役割ごとの StatelessWidget に分け直した版です。注目点は、親では画面構成だけを残し、共通 UI を MetricCard や TaskRow として切り出していることです。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Shipment Dashboard')),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
ShipmentSummarySection(),
SizedBox(height: 16),
TaskPanel(),
],
),
),
);
}
}
class ShipmentSummarySection extends StatelessWidget {
const ShipmentSummarySection({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFE8F3FF),
borderRadius: BorderRadius.circular(16),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日の出荷概要',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('締切まで 2 時間。未処理の伝票を先に確認します。'),
SizedBox(height: 16),
Row(
children: [
Expanded(child: MetricCard(label: '未処理', value: '12 件')),
SizedBox(width: 12),
Expanded(child: MetricCard(label: '要確認', value: '3 件')),
],
),
],
),
);
}
}
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
);
}
}
class TaskPanel extends StatelessWidget {
const TaskPanel({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFD6DCE5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'優先タスク',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 12),
TaskRow(
number: '1',
title: '出荷待ち伝票を確認する',
subtitle: '未処理 12 件',
actionLabel: '開く',
useFilledButton: true,
),
TaskRow(
number: '2',
title: '保留理由を確認する',
subtitle: '要確認 3 件',
actionLabel: '確認',
useFilledButton: false,
),
],
),
);
}
}
class TaskRow extends StatelessWidget {
const TaskRow({
super.key,
required this.number,
required this.title,
required this.subtitle,
required this.actionLabel,
required this.useFilledButton,
});
final String number;
final String title;
final String subtitle;
final String actionLabel;
final bool useFilledButton;
@override
Widget build(BuildContext context) {
final button = useFilledButton
? FilledButton(onPressed: () {}, child: Text(actionLabel))
: OutlinedButton(onPressed: () {}, child: Text(actionLabel));
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(child: Text(number)),
title: Text(title),
subtitle: Text(subtitle),
trailing: button,
);
}
}
コードのポイント
① 親の build は画面構成だけを読む形になっている
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Shipment Dashboard')),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
ShipmentSummarySection(),
SizedBox(height: 16),
TaskPanel(),
],
),
),
);
親の build で見るべき情報が、ShipmentSummarySection と TaskPanel という役割名に集約されました。これで「何を並べている画面か」を先に把握し、そのあと必要な部品だけを開いて追える構成になります。
② 共通 UI は名前付き Widget と引数へ寄せている
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
});
final String label;
final String value;
MetricCard が必要な入力を label と value に絞って受け取るため、同じ見た目を保ったまま中身だけ差し替えられます。StatelessWidget へ切り出すときは、まず「この塊に名前を付けたいか」で考えると判断しやすく、SummarySection や TaskPanel のように役割が読めるなら分割する価値があります。
5. プロパティで値を渡すと再利用しやすくなる
次は、親がデータと押下処理を持ち、子の ShipmentCard は表示だけを担当する形にします。これが StatelessWidget でよく使う基本形です。
lib/main.dart は次の内容で作成します。
このファイルは、親がデータとイベントを持ち、子が表示に専念する基本形を確認するサンプルです。注目点は、ShipmentCard に必要な表示値と onOpen だけを渡して責務を分けていることです。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final shipments = [
const ShipmentCardData(
title: '東京営業所向け',
status: '未処理',
itemCount: 12,
accentColor: Color(0xFFE8F3FF),
),
const ShipmentCardData(
title: '名古屋センター向け',
status: '保留あり',
itemCount: 3,
accentColor: Color(0xFFFFF4E5),
),
const ShipmentCardData(
title: '大阪倉庫向け',
status: '完了間近',
itemCount: 5,
accentColor: Color(0xFFEAF8EC),
),
];
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Shipment Cards')),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: shipments.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final shipment = shipments[index];
return ShipmentCard(
title: shipment.title,
status: shipment.status,
itemCount: shipment.itemCount,
accentColor: shipment.accentColor,
onOpen: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${shipment.title} を開きます')),
);
},
);
},
),
),
);
}
}
class ShipmentCardData {
const ShipmentCardData({
required this.title,
required this.status,
required this.itemCount,
required this.accentColor,
});
final String title;
final String status;
final int itemCount;
final Color accentColor;
}
class ShipmentCard extends StatelessWidget {
const ShipmentCard({
super.key,
required this.title,
required this.status,
required this.itemCount,
required this.accentColor,
required this.onOpen,
});
final String title;
final String status;
final int itemCount;
final Color accentColor;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFD6DCE5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: accentColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
status,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text('対象伝票: $itemCount 件'),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: onOpen,
child: const Text('詳細を開く'),
),
),
],
),
);
}
}
コードのポイント
① 親は並べるデータと押下時の処理を決めている
itemBuilder: (context, index) {
final shipment = shipments[index];
return ShipmentCard(
title: shipment.title,
status: shipment.status,
itemCount: shipment.itemCount,
accentColor: shipment.accentColor,
onOpen: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${shipment.title} を開きます')),
);
},
);
親は「どのデータを並べるか」と「押されたら何をするか」だけを決めています。ShipmentCard 自体は通知の出し方を知らず、一覧の文脈を持つ親が onOpen で振る舞いを注入する構造です。
② 子は表示に必要な材料だけを受け取る
class ShipmentCard extends StatelessWidget {
const ShipmentCard({
super.key,
required this.title,
required this.status,
required this.itemCount,
required this.accentColor,
required this.onOpen,
});
StatelessWidget に渡す引数は、その部品が表示に必要な材料へ絞ると役割が明確になります。今回なら title status itemCount accentColor onOpen で足りるため、親しか使わない処理の詳細まで子へ持ち込まずに済みます。callback を渡せば、子は見た目を保ったまま押下時の処理だけ親へ委ねられます。
6. いつ分割するかを 3 つの観点で決める
実装中に迷ったら、次の 3 つで判断すると止まりにくくなります。
| 観点 | 分割を考えるサイン | 例 |
|---|---|---|
| 見た目のまとまり | その部分に名前を付けたい | UserProfileHeader ShipmentCard ActionToolbar |
| 重複 | 同じ形の UI が 2 回以上出る | メトリクスカード、一覧行、ラベル付きボタン |
| 引数差し替え | 中身だけ変えて再利用できる | タイトル、件数、色、押下処理だけ違うカード |
反対に、次のような場面では無理に切り出さなくて構いません。
| まだ分割しなくてよい場面 | 理由 |
|---|---|
| 1 回しか出ず、数行で意味が明確 | 親で読んだほうが流れを追いやすいため |
| 名前を付けてもかえって曖昧になる | Section1 のような抽象名では可読性が上がらないため |
| 引数が多すぎて責務が混ざっている | その部品の切り方自体を見直したほうがよいため |
分割しすぎを避けたいなら、親の build を上から読んだときに「画面の構成」が見えるかを確認します。小さすぎる部品へ割って逆に流れが見えなくなるなら、まだその段階ではありません。
もう 1 つの目安は、「その Widget 名を聞いて、何を表示する部品か想像できるか」です。ShipmentCard や TaskPanel なら役割が分かります。CustomBox1 のような名前しか付かないなら、切り方が曖昧な可能性があります。
7. まとめ
StatelessWidget での分割は、難しい設計の話から始めなくて構いません。まずは 1 画面の中で見た目のまとまりに名前を付け、親から子へ必要な値だけ渡すところから始めれば十分です。
迷ったときは、次の順で見ます。
- 名前を付けたい見た目のまとまりがあるか
- 同じ UI が 2 回以上出るか
- 引数を変えるだけで再利用できるか
この 3 つに当てはまるなら、StatelessWidget へ切り出す価値があります。次に REST API 通信やフォーム画面へ進むときも、この分割を先にしておくと、表示と処理の境界を保ちやすくなります。