Flutter でリフレッシュ可能な無限スクロールを実装する
無限スクロールを実装する際、リフレッシュ機能を付けたいことがあります。たとえばソート順やフィルター条件を変更したときに、読み込んだリストを破棄して最初から読み込み直したいときなどです。この記事では、そのようなリフレッシュ機能を実装する際に注意すべき点を紹介します。
実装
昇順・降順のソートを切り替えられるタスクリストを例にとって、まずは愚直に実装してみます。StatefulWidget
を使うとして、以下のようなステートを用意しました。
// 現在表示しているタスクのリスト
final _tasks = <Task>[];
// タスクのソート順
TaskOrder _order = TaskOrder.asc;
// 読み込み中に連続して追加の読み込みが発生しないようにするためのフラグ
bool _isLoading = false;
// まだ読み込んでいないタスクがあるかどうか
bool _hasMore = true;
build
メソッドは以下のようになります。ソート切り替えボタンと ListView
の部分は独立したウィジェットに切り出す想定で、記事内での説明は省略します。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
actions: [
TaskSortButton(
value: _order,
onChanged: (order) {
setState(() {
_order = order;
});
// ソート順が変わったので、リストをリセットする
_refresh();
},
),
],
),
body: TaskListView(
tasks: _tasks,
// リストの末尾までスクロールしたら、次のページを読み込む
onScrollToEnd: _loadMore,
hasMore: _hasMore,
),
);
}
_loadMore
の実装は以下です。
Future<void> _loadMore() async {
// 読み込み中 or すべて読み込み済みなら何もしない
if (_isLoading || !_hasMore) {
return;
}
_isLoading = true;
// ここではページネーションにシーク法 (カーソルベース) を使っている
final lastTask = _tasks.lastOrNull;
final tasks = await widget.repository.fetchPage(
lastTask: lastTask,
order: _order,
);
_isLoading = false;
setState(() {
_tasks.addAll(tasks);
// ここでは読み込み結果が空ならそれ以上のデータはない想定
_hasMore = tasks.isNotEmpty;
});
}
_refresh
の実装は以下です。
Future<void> _refresh() async {
_isLoading = false;
setState(() {
_tasks.clear();
_hasMore = true;
});
await _loadMore();
}
これでソート順を変更可能な無限スクロールが実装できました。
しかし、この実装には問題があります。
問題点
問題は、読み込みの非同期処理が完了する前にリフレッシュを実行した際に発生します。リフレッシュした後に読み込みが完了すると、リフレッシュ前のデータがリストに追加され、順序の整合性が崩れてしまいます。
解決策 1: ステートのリビジョンを導入する
シンプルな解決策として、ステートのリビジョンを導入する方法があります。リビジョンとは、リフレッシュを実行するたびにインクリメントされる整数値です。非同期処理の前後でリビジョンが変わっていたら、非同期処理の結果を無視するようにします。
int _revision = 0;
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) {
return;
}
_isLoading = true;
// 非同期処理前のリビジョンを保持しておく
final revision = _revision;
final lastTask = _tasks.lastOrNull;
final tasks = await widget.repository.fetchPage(
lastTask: lastTask,
order: _order,
);
_isLoading = false;
// 非同期処理後のリビジョンと比較して、異なっていたら無視する
if (revision != _revision) {
return;
}
setState(() {
_tasks.addAll(tasks);
_hasMore = tasks.isNotEmpty;
});
}
Future<void> _refresh() async {
// リビジョンをインクリメントする
_revision++;
_isLoading = false;
setState(() {
_tasks.clear();
_hasMore = true;
});
await _loadMore();
}
これでリフレッシュ中に読み込みが完了しても、リビジョンが変わっているので読み込み結果が無視されます。
解決策 2: 非同期処理のキャンセレーションを導入する
発展的な解決策として、非同期処理のキャンセレーションを導入する方法も考えられます。ここでは async
パッケージ (dart:async
とは別) を使って実装します。
import 'package:async/async.dart';
// ...
// キャンセル可能な非同期処理
CancelableOperation<List<Task>>? _fetchTasksOperation;
// `_fetchTasksOperation` が non-null かつ未完了なら読み込み中
bool get _isLoading => switch (_fetchTasksOperation) {
final op? when !op.isCompleted && !op.isCanceled => true,
_ => false,
};
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) {
return;
}
// 読み込み処理の Future からキャンセル可能な非同期処理を作成する
// (後の処理で `_fetchTasksOperation` を直接参照すると non-null であることが保証されない
// ので、ローカル変数にシャドーイングしている)
final op = _fetchTasksOperation = CancelableOperation.fromFuture(
widget.repository.fetchPage(
lastTask: _tasks.isNotEmpty ? _tasks.last : null,
order: _order,
),
);
// キャンセルされた場合はこのコールバックは呼ばれない
return op.then((tasks) {
setState(() {
_tasks.addAll(tasks);
_hasMore = tasks.isNotEmpty;
});
// 一応読み込み完了 or キャンセル時に complete する Future を返しておく
}).valueOrCancellation();
}
Future<void> _refresh() async {
_fetchTasksOperation?.cancel();
setState(() {
_tasks.clear();
_hasMore = true;
});
await _loadMore();
}
一応こんな方法もあるということで書いてみましたが、結構複雑になってしまいました。この例ではやっていませんが、読み込みリクエストまでキャンセルしたい場合などには有効かなと思います。
まとめ
リフレッシュ可能な無限スクロールのプラクティスを紹介しました。無限スクロールに限らず、非同期処理によってステートの整合性が崩れないか注意を払うようにしましょう。
今回のサンプルコードは GitHub にあります。main
ブランチが解決策を導入する前のコード、state-revision
ブランチがステートのリビジョンを導入したコード、async-cancelation
ブランチが非同期処理のキャンセレーションを導入したコードです。
コメントを残す