商品マスタや取引先一覧のように、件数が数百を超える一覧を1画面で扱うときの入口です。検索フォーム、ページ単位の取得、スクロールでの追加読み込み、そして取得中に検索条件が変わったときの扱いまでを、外部パッケージなしの lib/main.dart 1ファイルで組みます。
1. ゴールとこの段階でやらないこと
対象読者
- Flutter で
ListViewとTextFieldを使った画面は作れる人 - 一覧の件数が増えて、全件取得のままでは扱いにくくなってきた人
- 検索とページングを同じ画面に載せたときの状態の持ち方を決めたい人
ゴール
- 検索条件とページ番号を状態として分けて持てる
- スクロール位置を見て次のページだけを追加取得できる
- 入力のたびに取得せず、入力が落ち着いてから検索できる
- 検索条件が変わったときに、古い条件のレスポンスを結果へ混ぜない
この段階でやらないこと
- 実際の HTTP 通信(
http/dioへの差し替えは最後に触れます) - Riverpod などの状態管理パッケージの導入
- 取得失敗時のエラー表示と再試行(→ Flutterでローディング・空状態・エラー表示を整える)
- 検索条件の永続化、絞り込みダイアログ、並び替え
通信そのものは FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) で扱っている。この記事では 600ms 待って結果を返す擬似 Repository を使い、画面側の状態遷移だけに絞ります。
2. 検索とページングが噛み合わない場所
全件取得の一覧なら、状態は「取得したリスト」ひとつで足りる。ページングを入れると、画面が覚えることが増えます。
- 今どの検索条件で取得したのか
- 次に取るのは何ページ目か
- まだ続きがあるのか
- 今まさに取得中か
この4つが揃わないと、同じページを二重に足したり、最終ページの先を取りにいったりする。とくに事故になりやすいのが、取得の最中に検索条件が変わるケースです。
sequenceDiagram
participant U as 利用者
participant S as 画面の状態
participant R as Repository
U->>S: blt で検索
S->>R: search(keyword: blt, page: 0)
U->>S: nut へ入力し直す
S->>S: 一覧をクリアして page: 0 へ戻す
S->>R: search(keyword: nut, page: 0)
R-->>S: blt の結果(先に投げた分が後から届く)
R-->>S: nut の結果
Note over S: 到着順に足すと、ボルトとナットが混ざる
ネットワークは投げた順に返るとは限りません。blt の取得が遅れている間に nut へ切り替えると、後から届いた blt の結果が nut の一覧へ紛れ込む経路がある。取得ごとに通し番号を持たせ、自分が最新の取得かどうかを、結果を反映する直前に確かめるのが今回の対処です。
3. lib/main.dart に検索付きの一覧を組む
擬似 Repository とページング画面をまとめた1ファイルです。状態をどこに持ち、どの条件で次のページを取るかに注目してください。
このサンプルは flutter run で確認できます。外部パッケージ不要のため、コードそのものは DartPad でも確認できます。
lib/main.dart は次の内容に書き換えます。
import 'dart:async';
import 'package:flutter/material.dart';
void main() {
runApp(const ItemSearchApp());
}
class Item {
const Item({required this.code, required this.name, required this.category});
final String code;
final String name;
final String category;
}
class SearchResultPage {
const SearchResultPage({
required this.items,
required this.hasMore,
required this.totalCount,
});
final List<Item> items;
final bool hasMore;
final int totalCount;
}
class ItemRepository {
ItemRepository() : _all = _generateItems();
final List<Item> _all;
static List<Item> _generateItems() {
const List<(String prefix, String category)> categories =
<(String, String)>[
('BLT', 'ボルト'),
('NUT', 'ナット'),
('WSR', 'ワッシャー'),
('BRG', 'ベアリング'),
];
return List<Item>.generate(120, (int index) {
final (String prefix, String category) =
categories[index % categories.length];
return Item(
code: '$prefix-${1000 + index}',
name: '$category M${4 + (index % 12)}',
category: category,
);
});
}
Future<SearchResultPage> search({
required String keyword,
required int page,
int pageSize = 20,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 600));
final List<Item> matched = _all
.where((Item item) {
if (keyword.isEmpty) {
return true;
}
final String query = keyword.toLowerCase();
return item.code.toLowerCase().contains(query) ||
item.name.toLowerCase().contains(query);
})
.toList(growable: false);
final int start = page * pageSize;
if (start >= matched.length) {
return SearchResultPage(
items: const <Item>[],
hasMore: false,
totalCount: matched.length,
);
}
final int end = start + pageSize < matched.length
? start + pageSize
: matched.length;
return SearchResultPage(
items: matched.sublist(start, end),
hasMore: end < matched.length,
totalCount: matched.length,
);
}
}
class ItemSearchApp extends StatelessWidget {
const ItemSearchApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Item Search Paging Sample',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const ItemSearchPage(),
);
}
}
class ItemSearchPage extends StatefulWidget {
const ItemSearchPage({super.key});
@override
State<ItemSearchPage> createState() => _ItemSearchPageState();
}
class _ItemSearchPageState extends State<ItemSearchPage> {
final ItemRepository _repository = ItemRepository();
final ScrollController _scrollController = ScrollController();
final TextEditingController _keywordController = TextEditingController();
final List<Item> _items = <Item>[];
String _appliedKeyword = '';
int _nextPage = 0;
int _totalCount = 0;
bool _hasMore = true;
bool _isLoading = false;
int _requestId = 0;
Timer? _debounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadNextPage();
}
@override
void dispose() {
_debounce?.cancel();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_keywordController.dispose();
super.dispose();
}
void _onKeywordChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_applyKeyword(value.trim());
});
}
void _applyKeyword(String keyword) {
if (keyword == _appliedKeyword) {
return;
}
setState(() {
_appliedKeyword = keyword;
_items.clear();
_nextPage = 0;
_totalCount = 0;
_hasMore = true;
_isLoading = false;
});
_loadNextPage();
}
void _onScroll() {
if (!_scrollController.hasClients) {
return;
}
final ScrollPosition position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) {
_loadNextPage();
}
}
Future<void> _loadNextPage() async {
if (_isLoading || !_hasMore) {
return;
}
final int requestId = ++_requestId;
final String keyword = _appliedKeyword;
final int page = _nextPage;
setState(() {
_isLoading = true;
});
final SearchResultPage result = await _repository.search(
keyword: keyword,
page: page,
);
if (!mounted || requestId != _requestId) {
return;
}
setState(() {
_items.addAll(result.items);
_nextPage = page + 1;
_totalCount = result.totalCount;
_hasMore = result.hasMore;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('商品マスタ検索')),
body: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _keywordController,
onChanged: _onKeywordChanged,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
labelText: 'コード / 品名で検索',
hintText: '例: BLT-1000 / ボルト',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: <Widget>[
Expanded(
child: Text(
_appliedKeyword.isEmpty
? '全件表示'
: '「$_appliedKeyword」の検索結果',
style: Theme.of(context).textTheme.bodyMedium,
),
),
Text(
'$_totalCount 件中 ${_items.length} 件表示',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const Divider(height: 16),
Expanded(child: _buildResultArea()),
],
),
);
}
Widget _buildResultArea() {
if (_items.isEmpty && _isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'「$_appliedKeyword」に一致する商品がありません。コードの一部か品名で試してください。',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
return ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
);
}
final Item item = _items[index];
return ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text('${item.code} / ${item.name}'),
subtitle: Text(item.category),
);
},
);
}
}
コードのポイント
① Repository はページ単位で返し、続きの有無も一緒に返す
Future<SearchResultPage> search({
required String keyword,
required int page,
int pageSize = 20,
}) async {
戻り値を List<Item> にせず、hasMore と totalCount を同梱した SearchResultPage にしています。「20 件返ってきたから、たぶんまだ続きがある」と件数から推測する形にすると、総件数がページサイズの倍数のときに空ページを1回余計に取りにいく。続きの有無はデータを持っている側が答えるほうが、画面から推測ロジックが消えます。
② 入力中の文字列と、取得に使った検索条件を分ける
final TextEditingController _keywordController = TextEditingController();
final List<Item> _items = <Item>[];
String _appliedKeyword = '';
_keywordController.text は利用者が今打っている途中の文字列で、_appliedKeyword は実際に取得へ使った条件です。2ページ目を取るときに入力欄を直接読むと、1ページ目と違う条件で続きを取ることになり、一覧の中で条件が食い違う。ページングの間ずっと同じ条件を使うために、確定した条件を状態として別に持ちます。
③ 入力が落ち着いてから検索する
void _onKeywordChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
_applyKeyword(value.trim());
});
}
1文字ごとに _applyKeyword を呼ぶと、blt と打つだけで3回の取得が走ります。前のタイマーを cancel() してから張り直すことで、最後の入力から 300ms 何も打たれなかったときだけ検索が動く。dispose での _debounce?.cancel() は、画面を離れた後にタイマーが発火して setState を呼ぶのを防ぐためのものです。
④ 取得の重複と終端を、2つのフラグで止める
Future<void> _loadNextPage() async {
if (_isLoading || !_hasMore) {
return;
}
_onScroll はスクロール中に何十回も呼ばれます。この 1 行が無いと、しきい値を超えた瞬間に同じページの取得が並列で走り、一覧へ二重に追加される。_hasMore が false なら最終ページまで到達済みなので、それ以上は取りにいきません。
⑤ 古い取得の結果は、反映の直前に捨てる
final int requestId = ++_requestId;
final String keyword = _appliedKeyword;
final int page = _nextPage;
setState(() {
_isLoading = true;
});
final SearchResultPage result = await _repository.search(
keyword: keyword,
page: page,
);
if (!mounted || requestId != _requestId) {
return;
}
取得ごとに _requestId を1つ進め、その値をローカル変数へ控えます。await の後で _requestId が自分の番号から変わっていれば、その間に新しい検索が始まった証拠なので、結果を捨てて setState を呼ばない。§2 のシーケンス図で混ざっていた blt の結果は、ここで落ちます。_applyKeyword 側で _isLoading を false へ戻しているのは、進行中の取得を待たずに新しい条件の取得へ進むためです。
mounted の確認も同じ位置にある。画面を閉じた後に setState を呼ぶと例外になるため、await をまたぐ処理では戻り先が生きているかを確かめます。
4. flutter run で挙動を確認する
lib/main.dart を保存したら、次のコマンドで起動します。
flutter run
起動直後は検索条件なしの全件表示で、120 件のうち先頭 20 件だけが読み込まれる。ヘッダーの右側で、総件数に対して今いくつ表示しているかを確認できます。
4-1. スクロールで追加読み込みを確認する
一覧の末尾までスクロールすると、リスト下端にスピナーが出て次のページの取得が走ります。maxScrollExtent - 200 をしきい値にしているため、末尾へ着く手前で読み込みが始まる。取得中のヘッダーは「120 件中 20 件表示」のままで、20 件目の下にスピナーだけが増えている状態になる。
600ms 後にページ 1 が届くと、ヘッダーは「120 件中 40 件表示」へ変わり、スピナーの位置に 21 件目以降が挿し込まれます。
4-2. 検索で条件を絞る
検索欄に blt と入力すると、入力が止まってから 300ms 後に取得が走り、一覧が 30 件へ絞られます。ページ番号は 0 へ戻るため、絞り込み後の続きも先頭から数え直しになる。小文字で打ったキーワードが大文字の BLT-1000 に一致するのは、search の中で双方を toLowerCase() してから比較しているためです。
絞り込んだ後も「30 件中 20 件表示」で止まっている点に注目してください。検索結果が 1 ページに収まらないときは、ページングがそのまま効き続ける。
4-3. 0 件のときの見え方
一致しない条件を入れると、スピナーではなく 0 件の案内へ切り替わります。取得に失敗したのか、成功した上で 0 件なのかは画面からは区別できないため、文言で次の行動を示しておく。
5. 実 API へ差し替えるときの境界
ItemRepository を http や dio を使う実装へ置き換えても、成功応答を返す限りページング側の状態はそのまま使えます。差し替えでまず増えるのは、サーバーが hasMore に相当する情報を返すかどうかの判定です。
- 総件数を返す API なら、
totalCountと取得済み件数を比べて続きの有無を決められる nextの URL やカーソルを返す API なら、_nextPageの代わりにカーソル文字列を状態へ持つ- どちらも返さない API では、「ページサイズ未満なら終端」という判定に頼ることになり、境界で空取得が1回増える
ページ番号ではなくカーソルを使う場合も、§3 の _requestId と _appliedKeyword の役割はそのまま残る。取得の並びが入れ替わる可能性と、条件を途中で変えられる可能性は、通信の方式が変わっても消えません。
実 API では通信やデコードが例外を投げます。今回の _loadNextPage は擬似 Repository が失敗しない前提で try / catch を持たないため、そのまま差し替えると例外時に _isLoading が true のまま残り、スピナーが消えず以後の取得も _isLoading ガードで止まります。実装を移すときは _loadNextPage に try / catch(または finally)を足し、例外時にも _isLoading を戻す分岐を必ず入れます。取得失敗の見せ方は、Flutterでローディング・空状態・エラー表示を整える の 3 状態の分け方をこの画面へ持ち込むと、0 件と失敗が同じ見え方になるのを避けられます。状態管理を setState から移す段階なら、Flutterの状態管理入門(Riverpod最小構成) が入口です。
6. まとめ
検索とページングを同じ画面へ乗せると、入力中の文字列、確定した検索条件、次のページ番号、終端、取得中フラグが同時に動きます。今回の _appliedKeyword と _requestId は、そのうち一覧へ結果が混ざる2つの経路——2ページ目を入力欄の現在値で取ってしまう経路と、古い条件のレスポンスが後から届く経路——を塞ぐためのものだ。Repository を実 API へ置き換えても、この2つの役割はそのまま残ります。