公開日 2026-09-23

Flutterで一覧検索とページングを実装する(業務検索UIの最小構成)

Flutterで検索フォームとページ単位の取得を組み合わせ、スクロールでの追加読み込み、検索条件の保持、取得中に条件が変わったときの古い結果の破棄までを外部パッケージなしで実装する。

目次

  1. 1. ゴールとこの段階でやらないこと
  2. 対象読者
  3. ゴール
  4. この段階でやらないこと
  5. 2. 検索とページングが噛み合わない場所
  6. 3. lib/main.dart に検索付きの一覧を組む
  7. コードのポイント
  8. 4. flutter run で挙動を確認する
  9. 4-1. スクロールで追加読み込みを確認する
  10. 4-2. 検索で条件を絞る
  11. 4-3. 0 件のときの見え方
  12. 5. 実 API へ差し替えるときの境界
  13. 6. まとめ

商品マスタや取引先一覧のように、件数が数百を超える一覧を1画面で扱うときの入口です。検索フォーム、ページ単位の取得、スクロールでの追加読み込み、そして取得中に検索条件が変わったときの扱いまでを、外部パッケージなしの lib/main.dart 1ファイルで組みます。

1. ゴールとこの段階でやらないこと

対象読者

  • Flutter で ListView と TextField を使った画面は作れる人
  • 一覧の件数が増えて、全件取得のままでは扱いにくくなってきた人
  • 検索とページングを同じ画面に載せたときの状態の持ち方を決めたい人

ゴール

  • 検索条件とページ番号を状態として分けて持てる
  • スクロール位置を見て次のページだけを追加取得できる
  • 入力のたびに取得せず、入力が落ち着いてから検索できる
  • 検索条件が変わったときに、古い条件のレスポンスを結果へ混ぜない

この段階でやらないこと

通信そのものは 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 件だけが読み込まれる。ヘッダーの右側で、総件数に対して今いくつ表示しているかを確認できます。

起動直後の全件表示:120件中20件表示と出て、BLT-1000から始まる一覧が並んでいる

4-1. スクロールで追加読み込みを確認する

一覧の末尾までスクロールすると、リスト下端にスピナーが出て次のページの取得が走ります。maxScrollExtent - 200 をしきい値にしているため、末尾へ着く手前で読み込みが始まる。取得中のヘッダーは「120 件中 20 件表示」のままで、20 件目の下にスピナーだけが増えている状態になる。

スクロール中の追加読み込み:20件目の下にスピナーが表示され、ヘッダーは120件中20件表示のまま続きを取得している状態

600ms 後にページ 1 が届くと、ヘッダーは「120 件中 40 件表示」へ変わり、スピナーの位置に 21 件目以降が挿し込まれます。

4-2. 検索で条件を絞る

検索欄に blt と入力すると、入力が止まってから 300ms 後に取得が走り、一覧が 30 件へ絞られます。ページ番号は 0 へ戻るため、絞り込み後の続きも先頭から数え直しになる。小文字で打ったキーワードが大文字の BLT-1000 に一致するのは、search の中で双方を toLowerCase() してから比較しているためです。

絞り込んだ後も「30 件中 20 件表示」で止まっている点に注目してください。検索結果が 1 ページに収まらないときは、ページングがそのまま効き続ける。

blt で検索した結果:30件中20件表示となり、BLTで始まるボルトだけの一覧に絞られた状態

4-3. 0 件のときの見え方

一致しない条件を入れると、スピナーではなく 0 件の案内へ切り替わります。取得に失敗したのか、成功した上で 0 件なのかは画面からは区別できないため、文言で次の行動を示しておく。

ZZZで検索した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つの役割はそのまま残ります。

シリーズ 42/42

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)
  39. 39. Flutterの時計注入を用途で分ける:業務時刻とクールダウンを混ぜない
  40. 40. Flutterでdioとinterceptorでトークンリフレッシュを実装する(401で自動更新・再試行)
  41. 41. Flutterでフォーム入力とバリデーションを組む(Form・TextFormField最小構成)
  42. 42. Flutterで一覧検索とページングを実装する(業務検索UIの最小構成) 現在の記事