😆

Flutter × Firebaseでページネーションを実装する

2023/04/14に公開

FlutterとFirebaseでアプリを作っていて、何かのリスト表示をしたい時、アイテム数が多いとFirebaseの読み取り量が増えてしまいます。
例えば、タイムラインとか、ユーザー検索結果の表示とか。

Firebaseは従量課金制なので、コストに大きく影響します。
特に、私のように個人開発でアプリをリリースしている人にとっては、コスパを重視した設計は結構重要な視点かなと思います。

ということで今回は、リストを一気に読み込まず、例えば10個読み込んで、リストの最下部にボタンを配置して、押したらさらに読み込む、という実装をしたいと思います。いわゆるページネーションです。

前提条件

  1. FlutterとFirestoreの接続が終わっている
  2. 状態管理がちょっとわかる(今回は、riverpodを使いますが、状態管理にフォーカスしていないので、詳しくなくて大丈夫です)
  3. MVVMモデルをちょっと知っている

サンプルアプリの概要

firestoreに名前のドキュメントが30個くらい入っていて、それをテキストボタンを押すたびに10個ずつ取ってくる、というシンプルなものです。

動画で見てみるとこんな感じ

ざっくりイメージ図

行うことのイメージとしては、この図になります。
見にくくてすいません。ぜひ拡大してください。

①db.dart経由で、Firestoreからデータを取ってくる
②&③db.dart => viewModel.dart でデータを受けて、2つの変数に値を入れ込みます。
・名前リスト1(firestoreからデータを取ってくるたびに中身が最新になる)
・名前リスト2(firestoreからデータを取ってくるたびにリスト1を経由して、どんどん溜まっていく)
④main.dartで画面表示をしています。そこに表示される名前一覧は、上記の名前リスト2を見ています。
⑤もっと読み込む を押すと、①〜③が再度実行されて、表示が増えていきます。
⑥そして、⑤までを繰り返してきて、いよいよデータの最後になったら、名前リスト1が空になります。firestoreから、空の配列が帰ってきているということ。そしたら、「結果は以上です」という表記が出ることになります。

viewModel.dartで名前を格納する変数を2つ用意しているのは、⑥の動作のためです。

実際のコード

main.dart

main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final model = ref.watch(viewModel);

    return Scaffold(
      appBar: AppBar(
          automaticallyImplyLeading: false,
          title: const Text("ページネーション"),
          actions: [
            Row(
              children: [
                TextButton(onPressed: () => model.getNames(ref), child: const Text("取得"))
              ],
            ),
          ]),
      body: model.stackedNameList.isEmpty //ここで見ているのは、イメージ図の「名前リスト2」です
          ? const Center(child: Text("まだ何もありません"))
          : Padding(
              padding: const EdgeInsets.all(10.0),
              child: SingleChildScrollView(
                child: Column(
                  children: [
                    ListView.builder(
                        shrinkWrap: true,
                        physics: const NeverScrollableScrollPhysics(),
                        scrollDirection: Axis.vertical,
                        itemCount: model.stackedNameList.length,
                        itemBuilder: (context, int index) {
                          final name = model.stackedNameList[index];
                          return Center(
                              child: Padding(
                            padding: const EdgeInsets.all(10.0),
                            child: Text(name),
                          ));
                        }),
                    model.currentNameList.isEmpty//ここで見ているのは、イメージ図の「名前リスト1」です
                        ? const Center(child: Text("結果は以上です"))
                        : TextButton(
                            onPressed: () => model.getNamesNext(ref),
                            child: const Text("もっと読み込む"))
                  ],
                ),
              ),
            ),
    );
  }
}


1.右上の「取得」を押すと、Firebaseからデータを取得します(10件)。
2.「もっと読み込む」ボタンを押すと、さらにデータを取得します。
新たに表示するデータがもう無い場合は、「結果は以上です」の表示に切り替えます。

viewModel.dart

viewModel.dart
final viewModel = ChangeNotifierProvider((ref) => ViewModel());

class ViewModel with ChangeNotifier {

  List<String> stackedNameList = [];
  List<String> currentNameList = [];

  Future<void> getNames (WidgetRef ref) async {
    stackedNameList = [];
    currentNameList = await ref.read(dbManager).getNames();
    for (var element in currentNameList) {
      stackedNameList.add(element);
    }
    notifyListeners();
  }

  Future<void> getNamesNext (WidgetRef ref) async {
    currentNameList = await ref.read(dbManager).getNamesNext();
    for (var element in currentNameList) {
      stackedNameList.add(element);
    }
    notifyListeners();
  }

}

1.main.dartの「取得」ボタンを押すと、getNames メソッドが走って、dbManagerに処理を外注します。
2.さらに、「もっと読み込む」ボタンを押すと、getNamesNext メソッドが走って、dbManagerに外注します。
3.上記2つのメソッドで,dbManager経由でFirestoreから名前リストを取ってきて,
currentNameListと、stackedNameListに結果を格納しておきます。
4.取得できたら、 notifyListeners(); で、main.dartのMyHomePageクラスのbuildメソッドを再ビルドして、名前リストを表示させます。

dbManager.dart

dbManager.dart
final dbManager = ChangeNotifierProvider((ref) => DbManager());

class DbManager with ChangeNotifier {
  final db = FirebaseFirestore.instance;
  DocumentSnapshot? lastDoc;


  Future<List<String>> getNames () async{
    var result = <String>[];
   final query = await db.collection("users").limit(10).get();
      if(query.docs.isNotEmpty){
        for (var element in query.docs) {
          result.add(element.data()["name"]);
        }
      lastDoc = query.docs.last;
      }
      return result;
  }


  Future<List<String>> getNamesNext () async{
    var result = <String>[];
    final query = await db.collection("users").startAfterDocument(lastDoc!).limit(10).get();

    if(query.docs.isNotEmpty){
      for (var element in query.docs) {
        result.add(element.data()["name"]);
      }
      lastDoc = query.docs.last;
    }
    return result;
  }

}

dbManagerでは、Firestoreからのデータ取得を行います。
ここでのポイントは、
1. limit(10) で、ドキュメントの取得数を10個に制限しています。
2. DocumentSnapshot? lastDoc; を変数として設定。getNames()、getNamesNext()メソッド内で最後に取得したドキュメントを、lastDocに格納しておきます。

 lastDoc = query.docs.last;

↑↑この部分です。

3.そうしたら、getNamesNext()メソッド(<= もっと読み込む、を押したら走るメソッド)の、

startAfterDocument(lastDoc!)

この部分で、前回最後に読み込んだドキュメントの次のドキュメントから、読み込みを開始してくれます。

こんな流れで実装すると、ページネーション機能が作成できるはずです。
↓gitはこちら↓
https://github.com/Hiro-nari/pagenation_sample

もっといいやり方があったら、ぜひ教えていただけるとありがたいです!
ご指摘、ご質問などありましたらよろしくお願いします〜!

Discussion