🐣

[Flutter×Firebase] FirestoreとStateNotiferを使用してページネーションを実装する

9 min read

はじめに

Firestoreのコレクション内のドキュメントをListViewなどで一覧表示したいときがあります。
コレクション内のドキュメント数が少なければ全て取得してから表示しても問題ないですが、ドキュメント数が多くなってくると

  • 取得速度が低下する
  • 読み取り課金が余分に発生する(実際に表示しない分)

などの問題が出てきます。
なので、ドキュメント数が多いときにはページネーションを実装した方が良いと思います。

今回はFirestoreのlimitstartAfterを使って、簡単なページネーションの実装方法を紹介します。

取得するFirestore内のデータ


postsコレクションにドキュメントを500個ほど入れています。

ドキュメントのフィールドには並び替えに使うためにindexを入れています。
ページングを実装するためにstartAfterのクエリを使うのですが、orderByで並び替えてから使わないとエラーになってしまいます。

今回はわかりやすくindexを並び替えに使っていますが、実際のアプリでindexをドキュメント内に入れるのは現実的ではないので、Timestamp型のcreateAtなどを並び替えで使うかと思います。

データの状態をStateNotiferで実装する

StateNotifierを使って表示するデータの状態を管理します。

データの型は並び替え用のindexとドキュメントIDしかないので、Modelはシンプルにこれだけになります。

post_model.dart

class PostModel {
  const PostModel({
    required this.id,
    this.index,
  });

  final String id;
  final int? index;
}

PostsNotiferクラスには最初に表示するデータを取得するfetchFirstPostsと次のデータを取得するfetchPostsの2つの関数を実装します。
また、取得済みのドキュメントから次のドキュメントを取得するため、fetchedLastDocに取得した最後のドキュメントを保持しておきます。

posts_provider.dart
final postsProvider = StateNotifierProvider<PostsNotifier, List<PostModel>>(
  (ref) => PostsNotifier(),
);

class PostsNotifier extends StateNotifier<List<PostModel>> {
  PostsNotifier() : super([]);

  // 現在取得している最後のドキュメントを保持
  DocumentSnapshot? fetchedLastDoc;

  // 最初に表示するためのドキュメントを読み込む
  Future<void> fetchFirstPosts() async {
    // TODO: 後ほど実装
  }

  // 次のドキュメントを読み込む
  Future<void> fetchPosts() async {
    // TODO: 後ほど実装
  }
}

画面を作成する

Firestoreから取得したデータはListViewでindexとドキュメントIDのみ表示しています。

home_page.dart
class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Consumer(
        builder: (context, watch, child) {
          final state = watch(postsProvider);

          return ListView.builder(
            padding: const EdgeInsets.all(8),
            controller: scrollController,
            itemCount: state.length,
            itemBuilder: (context, index) {
              final item = state.elementAt(index);

              return Card(
                child: ListTile(
                  leading: CircleAvatar(
                    child: Text('${item.index}'),
                  ),
                  title: Text(item.id),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

最初のデータを取得する

取得したデータを表示する画面は作成したので、次に最初に表示するデータをFirestoreから取得します。

home_page.dart
class _HomePageState extends State<HomePage> {
+  
+  void initState() {
+    context.read(postsProvider.notifier).fetchFirstPosts();
+    super.initState();
+  }
  
  
  Widget build(BuildContext context) {...}
}

PostsNotifierクラスにfetchFirstPostsの処理を追加します。

posts_provider.dart
// 現在取得している最後のドキュメントを保持
DocumentSnapshot? fetchedLastDoc;

// 最初に表示するためのドキュメントを読み込む
Future<void> fetchFirstPosts() async {
-  // TODO: 後ほど実装
+  final snapshots = await FirebaseFirestore.instance
+      .collection('posts')
+      .orderBy('index')
+      .limit(20)
+      .get();
+
+  fetchedLastDoc = snapshots.docs.last;
+  state = [
+    ...snapshots.docs.map(
+      (e) => PostModel(
+        id: e.id,
+        index: e.data()['index'],
+      ),
+    ),
+  ];
}

// 次のドキュメントを読み込む
Future<void> fetchPosts() async {
  // TODO: 後ほど実装
}

これでアプリを実行すると最初の20件のドキュメントが取得され、画面に表示されます。

ScrollControllerを設定

次にListViewを最後までスクロールしたときに次のドキュメントを取得するため、ScrollControllerを設定します。

home_page.dart
class _HomePageState extends State<HomePage> {
+  late final ScrollController scrollController;
+
  
  void initState() {
    context.read(postsProvider.notifier).fetchFirstPosts();
+
+    scrollController = ScrollController();
+    scrollController.addListener(() {
+      if (scrollController.offset ==
+          scrollController.position.maxScrollExtent) {
+        // スクロールが最後に達した時、次のデータを取得する
+        context.read(postsProvider.notifier).fetchPosts();
+      }
+    });
    super.initState();
  }

+  
+  void dispose() {
+    scrollController.dispose();
+    super.dispose();
+  }
  
  
  Widget build(BuildContext context) {...}
}

PostsNotifierクラスにfetchPostsの処理を追加します。

posts_provider.dart
// 現在取得している最後のドキュメントを保持
DocumentSnapshot? fetchedLastDoc;

// 最初に表示するためのドキュメントを読み込む
Future<void> fetchFirstPosts() async {...}

Future<void> fetchPosts() async {
-  // TODO: 後ほど実装
+  final snapshots = await FirebaseFirestore.instance
+      .collection('posts')
+      .orderBy('index')
+      .startAfterDocument(fetchedLastDoc!)
+      .limit(20)
+      .get();
+
+  fetchedLastDoc = snapshots.docs.last;
+  state = [
+    ...state,
+    ...snapshots.docs.map(
+      (e) => PostModel(
+        id: e.id,
+        index: e.data()['index'],
+      ),
+    ),
+  ];
}

以上で、スクロールがListViewの最後に達したときに、次の20件のドキュメントを取得する処理ができました。

全部のコード
home_page.dart
class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  late final ScrollController scrollController;

  
  void initState() {
    // 最初に表示するデータを取得
    context.read(postsProvider.notifier).fetchFirstPosts();

    scrollController = ScrollController();
    scrollController.addListener(() {
      if (scrollController.offset ==
          scrollController.position.maxScrollExtent) {
        // スクロールが最後に達した時、次のデータを取得する
        context.read(postsProvider.notifier).fetchPosts();
      }
    });
    super.initState();
  }

  
  void dispose() {
    scrollController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sample App'),
      ),
      body: Consumer(
        builder: (context, watch, child) {
          final state = watch(postsProvider);

          return ListView.builder(
            padding: const EdgeInsets.all(8),
            controller: scrollController,
            itemCount: state.length,
            itemBuilder: (context, index) {
              final item = state.elementAt(index);

              return Card(
                child: ListTile(
                  leading: CircleAvatar(
                    child: Text('${item.index}'),
                  ),
                  title: Text(item.id),
                ),
              );
            },
          );
        },
      ),
    );
  }
}
posts_provider.dart
final postsProvider = StateNotifierProvider<PostsNotifier, List<PostModel>>(
  (ref) => PostsNotifier(),
);


class PostModel {
  const PostModel({
    required this.id,
    this.index,
  });

  final String id;
  final int? index;
}

class PostsNotifier extends StateNotifier<List<PostModel>> {
  PostsNotifier() : super([]);

// 現在取得している最後のドキュメントを保持
  DocumentSnapshot? fetchedLastDoc;

// 最初に表示するためのドキュメントを取得する
  Future<void> fetchFirstPosts() async {
    final snapshots = await FirebaseFirestore.instance
        .collection('posts')
        .orderBy('index')
        .limit(20)
        .get();

    fetchedLastDoc = snapshots.docs.last;
    state = [
      ...snapshots.docs.map(
        (e) => PostModel(
          id: e.id,
          index: e.data()['index'],
        ),
      ),
    ];
  }

  Future<void> fetchPosts() async {
    final snapshots = await FirebaseFirestore.instance
        .collection('posts')
        .orderBy('index')
        .startAfterDocument(fetchedLastDoc!)
        .limit(20)
        .get();

    fetchedLastDoc = snapshots.docs.last;
    state = [
      ...state,
      ...snapshots.docs.map(
        (e) => PostModel(
          id: e.id,
          index: e.data()['index'],
        ),
      ),
    ];
  }
}

参考

https://firebase.google.com/docs/firestore/query-data/query-cursors?hl=ja
https://medium.com/flutterdevs/pagination-in-flutter-with-firebase-firestore-96d6cc11aef2

さいごに

今回はスクロールが最後に達したときに次のドキュメントを取得するようにしましたが、インジケータWidgetを表示して読込中をユーザーに分かるようにした方が親切だと思います。

今回の実装はエラー時やコレクション内にデータが空の時の考慮をしていないので、実際に使うときにはこれらの状況もユーザーに分かるようなUIにしたほうが良いですね。

Discussion

ログインするとコメントできます