[Flutter×Firebase] FirestoreとStateNotiferを使用してページネーションを実装する
はじめに
Firestoreのコレクション内のドキュメントをListViewなどで一覧表示したいときがあります。
コレクション内のドキュメント数が少なければ全て取得してから表示しても問題ないですが、ドキュメント数が多くなってくると
- 取得速度が低下する
- 読み取り課金が余分に発生する(実際に表示しない分)
などの問題が出てきます。
なので、ドキュメント数が多いときにはページネーションを実装した方が良いと思います。
今回はFirestoreのlimit
とstartAfter
を使って、簡単なページネーションの実装方法を紹介します。
取得するFirestore内のデータ
postsコレクションにドキュメントを500個ほど入れています。
ドキュメントのフィールドには並び替えに使うためにindexを入れています。
ページングを実装するためにstartAfter
のクエリを使うのですが、orderByで並び替えてから使わないとエラーになってしまいます。
今回はわかりやすくindex
を並び替えに使っていますが、実際のアプリでindexをドキュメント内に入れるのは現実的ではないので、Timestamp型のcreateAt
などを並び替えで使うかと思います。
データの状態をStateNotiferで実装する
StateNotifierを使って表示するデータの状態を管理します。
データの型は並び替え用のindexとドキュメントIDしかないので、Modelはシンプルにこれだけになります。
class PostModel {
const PostModel({
required this.id,
this.index,
});
final String id;
final int? index;
}
PostsNotiferクラスには最初に表示するデータを取得するfetchFirstPosts
と次のデータを取得するfetchPosts
の2つの関数を実装します。
また、取得済みのドキュメントから次のドキュメントを取得するため、fetchedLastDoc
に取得した最後のドキュメントを保持しておきます。
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のみ表示しています。
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から取得します。
class _HomePageState extends State<HomePage> {
+
+ void initState() {
+ context.read(postsProvider.notifier).fetchFirstPosts();
+ super.initState();
+ }
Widget build(BuildContext context) {...}
}
PostsNotifierクラスにfetchFirstPosts
の処理を追加します。
// 現在取得している最後のドキュメントを保持
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を設定します。
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
の処理を追加します。
// 現在取得している最後のドキュメントを保持
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件のドキュメントを取得する処理ができました。
全部のコード
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),
),
);
},
);
},
),
);
}
}
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'],
),
),
];
}
}
参考
さいごに
今回はスクロールが最後に達したときに次のドキュメントを取得するようにしましたが、インジケータWidgetを表示して読込中をユーザーに分かるようにした方が親切だと思います。
今回の実装はエラー時やコレクション内にデータが空の時の考慮をしていないので、実際に使うときにはこれらの状況もユーザーに分かるようなUIにしたほうが良いですね。
Discussion