FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本) の次に、一覧画面を作ろうとして最初に迷いやすいのが ListView と GridView の選び分けです。止まりやすいのは Widget 名そのものより、「縦に流すべきか」「カードを面で並べるべきか」「builder はいつ使うのか」が曖昧なまま書き始めるところにあります。この記事では、Flutter 初学者向けに ListView ListView.builder GridView.count の基本パターンを整理し、最初の一覧画面を自力で組み始める基準をまとめます。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、
Column/Row/Stackの基本までは終わった人 ContainerとSizedBoxの役割分担は分かったが、一覧画面を何で組むかまだ曖昧な人ListViewとGridViewを見たことはあるが、どちらを選ぶか判断基準がない人
この記事で到達する状態
ListView(children: ...)で固定項目の一覧を作れるListView.builderでデータから繰り返し行を描画できるscrollDirectionで縦スクロールと横スクロールを切り替えられるGridView.countで 2 列のカード一覧を作れるListViewとGridViewの使い分けを説明できる
非対象
CustomScrollView/SliverList/SliverGrid- 無限スクロールやページング
- REST API 取得と状態管理
GridView.builderの詳細比較- パフォーマンス最適化の深掘り
状態管理や API 取得は次の記事へ回し、ここでは一覧の形とスクロールの基本を扱います。
2. 先に一覧の種類を分ける
最初に、ListView と GridView の役割を分けます。
| Widget | 向いている見せ方 | まず見るポイント | よくある場面 |
|---|---|---|---|
ListView | 1 列で上から下へ読む一覧 | children / builder / scrollDirection | メニュー一覧、通知一覧、履歴一覧 |
GridView | 複数列で面として見比べる一覧 | crossAxisCount / childAspectRatio | 商品カード、写真一覧、カテゴリ一覧 |
さらに ListView には 2 つの入り口があります。
- 項目数が少なく固定されているなら
ListView(children: [...]) - 同じ見た目の行をデータから繰り返すなら
ListView.builder(...)
判断を先に図で置くと、次のようになります。
flowchart TD
A[一覧画面を作りたい] --> B{1 列で読ませるか}
B -->|はい| C{項目数は少なく固定か}
C -->|はい| D[ListView children]
C -->|いいえ| E[ListView.builder]
B -->|いいえ| F[GridView.count]
縦一覧かグリッドかを先に決め、その後に children か builder かを選ぶ—並び方と作り方は別の軸です。
3. ListView は 1 列の一覧を作る
設定画面やメニューのように、数件の固定項目を順番に並べるだけなら ListView(children: ...) から始めると読みやすくなります。行ごとに表示内容が大きく違う場面でも、そのまま書き下ろせます。
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。
次の lib/main.dart は、固定項目を children に並べるだけで一覧画面になる最小例です。各行を個別に書き分けられる点に注目してください。
lib/main.dart は次の内容で作成します。
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('ListView children sample')),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
ListTile(
leading: Icon(Icons.inventory_2_outlined),
title: Text('入荷一覧'),
subtitle: Text('本日入荷予定の伝票を確認する'),
trailing: Icon(Icons.chevron_right),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.local_shipping_outlined),
title: Text('出荷一覧'),
subtitle: Text('処理待ちの出荷指示を見る'),
trailing: Icon(Icons.chevron_right),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.sync_outlined),
title: Text('同期状況'),
subtitle: Text('未送信データと最終同期時刻を確認する'),
trailing: Icon(Icons.chevron_right),
),
],
),
),
);
}
}
コードのポイント
① 固定項目は children に順番どおり並べるだけでよい
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
ListTile(
leading: Icon(Icons.inventory_2_outlined),
title: Text('入荷一覧'),
subtitle: Text('本日入荷予定の伝票を確認する'),
trailing: Icon(Icons.chevron_right),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.local_shipping_outlined),
title: Text('出荷一覧'),
subtitle: Text('処理待ちの出荷指示を見る'),
trailing: Icon(Icons.chevron_right),
),
Divider(height: 1),
ListTile(
leading: Icon(Icons.sync_outlined),
title: Text('同期状況'),
subtitle: Text('未送信データと最終同期時刻を確認する'),
trailing: Icon(Icons.chevron_right),
),
],
),
件数が固定なら、children に上から並べる形が最も素直です。どの行がどこに出るかをそのまま上から読めるため、初学者が一覧の構造を掴みやすくなります。
② 各行をその場で書き分けやすい
ListTile(
leading: Icon(Icons.inventory_2_outlined),
title: Text('入荷一覧'),
subtitle: Text('本日入荷予定の伝票を確認する'),
trailing: Icon(Icons.chevron_right),
),
固定メニューでは、行ごとにアイコンや説明が少しずつ違うことが多くあります。children 直書きなら、共通化を急がずに差分をそのまま表現できます。
項目数が 3 件から 5 件程度で固定なら、この書き方で十分です。最初の段階では builder に切り替える理由はまだありません。
4. ListView.builder で繰り返し描画する
同じ見た目の行をデータから繰り返し作るなら、ListView.builder を選びます。ここでの利点は「大量件数に耐える」ことだけではありません。同じ行を何度も手で書かずに済み、後で API 通信へつなげやすくなる点にあります。
まずは縦方向の ListView.builder を確認します。この lib/main.dart は、データ件数と行の描画処理を分ける最小例です。itemCount と itemBuilder の役割を切り分けて見ます。
lib/main.dart は次の内容で作成します。
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 = List.generate(8, (index) => '出荷指示 #${index + 1}');
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('ListView.builder sample')),
body: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: shipments.length,
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(shipments[index]),
subtitle: const Text('棚A-12 / 処理待ち'),
trailing: const Icon(Icons.chevron_right),
),
);
},
),
),
);
}
}
コードのポイント
① 件数は itemCount、見た目は itemBuilder で分ける
body: ListView.builder(
itemCount: shipments.length,
itemBuilder: (context, index) {
何件出すかと、1 件をどう描画するかが別れているため、一覧の責務が明確です。API 結果に差し替えるときも、まず shipments の作り方だけを変えれば済みます。
② index からその行のデータを引き当てる
return Card(
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(shipments[index]),
subtitle: const Text('棚A-12 / 処理待ち'),
),
);
index は表示順とデータ参照の両方に使えます。同じカード構造を繰り返しつつ、番号やタイトルだけを差し替える流れが見やすい構成です。
横スクロールへ切り替えるときは、同じ builder に scrollDirection: Axis.horizontal を足します。このときは高さを先に決める必要があります。次の lib/main.dart は、横方向カード列に必要な差分だけを足した例です。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final quickActions = [
'検品',
'入荷',
'出荷',
'棚卸',
'同期',
];
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Horizontal ListView.builder')),
body: Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: quickActions.length,
itemBuilder: (context, index) {
return Container(
width: 140,
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.apps_outlined, color: Colors.blue.shade700),
const SizedBox(height: 12),
Text(
quickActions[index],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
);
},
),
),
),
),
);
}
}
コードのポイント
① 横スクロールは scrollDirection の指定で切り替える
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: quickActions.length,
縦方向の builder と構造はほぼ同じで、違うのはスクロール方向です。まずこの差分だけを見ると、ListView.builder の応用として理解しやすくなります。
② 横方向では高さを先に固定する
child: SizedBox(
height: 120,
child: ListView.builder(
横スクロールの一覧は、高さが曖昧だと親レイアウトとの関係が読みづらくなります。SizedBox で先に高さを決めておくと、カードの大きさと余白の意図が追いやすくなります。
縦の履歴一覧、横のクイックメニュー、どちらも ListView.builder で組めます。違うのは並ぶ方向と 1 行の見せ方です。
5. GridView は複数列の一覧を作る
商品カードやカテゴリ一覧のように、同じ大きさの部品を面で並べたいなら GridView を選びます。行単位で読むより、複数項目を見比べやすい形になります。
最初は GridView.count で十分です。列数を crossAxisCount で決められるため、まず 2 列で並べて見た目を確認しやすくなります。
次の lib/main.dart は、同じ大きさのカードを面で並べるための最小構成です。列数とカード間隔をどこで決めているかを見ると、ListView との違いが分かります。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final categories = [
'食品',
'飲料',
'日用品',
'医薬品',
'備品',
'資材',
];
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('GridView.count sample')),
body: GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.4,
children: categories.map((category) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.green.shade100),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.category_outlined, color: Colors.green.shade700),
const SizedBox(height: 12),
Text(
category,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
);
}).toList(),
),
),
);
}
}
コードのポイント
① 列数は crossAxisCount で決める
body: GridView.count(
padding: const EdgeInsets.all(16),
crossAxisCount: 2,
GridView.count は、まず何列で見せるかを決めるところから始まります。2 列に固定すると、一覧全体の密度がすぐ見えるため、グリッドの入門例として追いやすい形です。
② 間隔と縦横比をそろえると、カードを見比べやすい
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.4,
グリッドはカード同士の見た目がそろっているほど比較しやすくなります。間隔と比率をまとめて決めると、どの情報量のカードに向いたレイアウトかを判断しやすくなります。
項目ごとの説明文が長くばらつくなら、無理に GridView へ寄せず ListView のほうが読みやすい場面もあります。グリッドは「同じ大きさで見比べたいか」を先に見ると選びやすくなります。
6. よくある詰まりどころを先に避ける
一覧 Widget はすぐ使えますが、最初に詰まりやすい場所が 2 つあります。どちらも見た目が崩れるのではなく、エラーになります。
6-1. Column の中に ListView をそのまま置く
Column は縦方向の高さを無制限に子へ渡すため、高さいっぱいに広がろうとする ListView と噛み合いません。
The following assertion was thrown during performResize():
Vertical viewport was given unbounded height.
Viewports expand in the scrolling direction to fill their container. In this case, a vertical
viewport was given an unlimited amount of vertical space in which to expand. This situation
typically happens when a scrollable widget is nested inside another scrollable widget.
If this widget is always nested in a scrollable widget there is no need to use a viewport because
there will always be enough vertical space for the children. In this case, consider using a Column
or Wrap instead. Otherwise, consider using a CustomScrollView to concatenate arbitrary slivers into
a single scrollable.
The relevant error-causing widget was:
ListView
The relevant error-causing widget was: の下に出るのが原因の Widget です。スタックトレースは RenderViewport や RenderFlex など framework 内部の名前で埋まるので、まずここを読みます。
直し方は Expanded で囲むことです。残り領域の高さが確定します。
Column(
children: [
const Text('見出し'),
Expanded(
child: ListView(
children: const [
ListTile(title: Text('1件目')),
ListTile(title: Text('2件目')),
],
),
),
],
)
6-2. 横スクロールの ListView を高さ未指定で置く
こちらは縦ではなく横の viewport なので、メッセージが変わります。
The following assertion was thrown during performResize():
Horizontal viewport was given unbounded height.
Viewports expand in the cross axis to fill their container and constrain their children to match
their extent in the cross axis. In this case, a horizontal viewport was given an unlimited amount of
vertical space in which to expand.
さらに、この直後に別のエラーが続けて出ます。
The following assertion was thrown during performLayout():
RenderBox was not laid out: RenderViewport#b2a53 NEEDS-LAYOUT NEEDS-PAINT
**RenderBox was not laid out は後続の症状であって原因ではありません。**サイズを決められなかった viewport を親が測ろうとして出ているだけなので、先に出ている ... viewport was given unbounded height. のほうを直します。
横スクロールのカード列は、高さを先に決めます。
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
// ...
),
)
6-3. 配置で迷ったときの 2 点
- 画面の残り領域いっぱいに一覧を置きたいなら、
Expanded(child: ListView(...))の形にする - 横スクロールのカード列を置きたいなら、
SizedBox(height: 120, child: ListView.builder(...))のように高さを先に決める
どちらも「一覧の高さを誰が決めるか」を先に確定させる、という同じ話です。
ここでは shrinkWrap や制約システムの詳細までは広げません。まずは「縦一覧には縦方向の高さが必要」「横一覧には見せたい高さが必要」という 2 点だけ押さえれば十分です。
7. 迷ったときの使い分け
よくある場面ごとに選ぶ理由を整理します。
| 場面 | 選ぶもの | 見る理由 |
|---|---|---|
| 設定画面のメニュー一覧 | ListView(children: ...) | 項目数が少なく、行ごとに内容が少しずつ違っても書き分けやすいから |
| 通知一覧や履歴一覧 | ListView.builder | 同じ行を件数分だけ繰り返しやすいから |
| 画面上部の横スクロール操作カード | ListView.builder + Axis.horizontal | 1 列のまま横へ流したいから |
| 商品カードやカテゴリ一覧 | GridView.count | 複数列で面として見比べやすいから |
| 写真サムネイル一覧 | GridView.count | 同じ大きさの画像を並べる前提と相性がよいから |
迷ったら、次の順で確認してください。
- 1 列で順番に読ませたいのか、面で見比べさせたいのか
- 項目数は少数固定か、同じ行を件数分だけ繰り返すか
- 縦に流すか、横へ流すか
一覧画面の最初の判断は、この 3 つを確認すれば数個の候補まで絞れます。
8. まとめ
ListView は 1 列の一覧、ListView.builder はデータから行を繰り返す一覧、GridView.count は複数列のカード一覧を作る入口です。まずは「縦に読むか、面で見比べるか」を決め、その後に children か builder かを選ぶと迷いにくくなります。
次に UI 基礎を進めるなら、通知 UI の基本としてダイアログやスナックバーへ進むか、実務寄りに REST API 通信へ進むと流れがつながります。一覧で詰まったら、並び方、件数、スクロール方向の 3 つを先に確認してください。