前々回の記事 Flutter でリフレッシュ可能な無限スクロールを実装する で StatefulWidget
を使った実装を紹介しましたが、今回はそれを Riverpod を使って実装してみます。
Notifier を使うこともできるけど……
リストをどんどん読み込んで増やしていくという挙動から、まず思いつくのは Notifier を使用する方法かもしれません。簡単にコードを例示します。
@riverpod
class Tasks extends _$Tasks {
late TaskOrder _order;
@override
Future<List<Task>> build({required TaskOrder order}) {
_order = order;
return _fetchTasks();
}
Future<void> loadMore() async {
if (state case AsyncData(:final value)) {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final page = await _fetchTasks(lastTask: value.lastOrNull);
return [...value, ...page];
});
}
}
Future<List<Task>> _fetchTasks({Task? lastTask}) {
final repository = ref.read(taskRepositoryProvider);
return repository.fetchPage(lastTask: lastTask, order: _order);
}
}
十分実用的ですが、手続き的であるといえます。また考え方によっては、どこまで読み込んだかはグローバルに持つべきでなく、読み込み側に持たせるべきともいえます。Riverpod はデータフェッチやキャッシュを宣言的なアプローチで実現できるようにするライブラリだと自分は思っているので、より宣言的な実装を考えてみます。
考え方
無限スクロールを宣言的に表すとしたら、どこまで読み込んだかのページ数と、ソートやフィルター条件が与えられれば、結果のリストが一意に決定できそうです。追加のページ読み込みはページ数をインクリメントすることで実行します。
Provider の定義
Provider はシンプルに、1つのページを取得するだけのものを定義します。
@riverpod
Future<List<Task>> tasksPage(
TasksPageRef ref, {
required TaskOrder order,
required Task? lastTask,
}) {
return ref.watch(taskRepositoryProvider).fetchPage(
lastTask: lastTask,
order: order,
);
}
残りの挙動は Widget 側で実装します。
Widget の実装
前述したように、どこまで読み込むかのページ数とソート順をステートとして定義します。
class _TaskIndexScreenState extends ConsumerState<TaskIndexScreen> {
TaskOrder _order = TaskOrder.asc;
int _currentPage = 1;
@override
Widget build(BuildContext context) {
// ...
}
}
build
メソッド内で、ステートに応じてページを読み込みます。
@override
Widget build(BuildContext context) {
final pages = <List<Task>>[];
var hasMore = true;
for (var i = 0; i < _currentPage; i++) {
final lastPage = pages.lastOrNull;
final page = ref
.watch(tasksPageProvider(order: _order, lastTask: lastPage?.lastOrNull));
if (page case AsyncValue(:final value?)) {
if (page.isNotEmpty) {
pages.add(page);
} else {
// ページが空ならそれ以上のアイテムはないとして中断
hasMore = false;
break;
}
} else {
// ページが読み込み中 or エラーならそこで中断 (エラーハンドリングは省略)
break;
}
}
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
actions: [
TaskSortButton(
value: order.value,
onChanged: (order) {
setState(() {
_order = order;
// ソート順が変わったら読み込み位置もリセット
_currentPage = 1;
});
},
),
],
),
body: TaskListView(
tasks: pages.expand((page) => page).toList(),
onScrollToEnd: () {
// 指定されたページ数に達していなければまだ読み込み中なので、追加の読み込みを抑制
if (pages.length == _currentPage && hasMore) {
setState(() {
_currentPage++;
});
}
},
hasMore: hasMore,
),
);
}
Riverpod のキャッシングメカニズムを利用して、宣言的に無限スクロールを実装できました 🚀
おまけ: Flutter Hooks でロジックを切り出す
Flutter Hooks を利用して、無限スクロールのロジックを再利用可能にしてみます。
// hook の戻り値を record で定義
typedef UseTasksResult = ({
List<Task> tasks,
VoidCallback loadMore,
bool hasMore,
});
// ソート順はこの hook の外から渡す
UseTasksResult useTasks({required WidgetRef ref, required TaskOrder order}) {
final currentPage = useState(1);
// ソート順が変わったら読み込み位置をリセット
useEffect(() {
currentPage.value = 1;
}, [order]);
final pages = <List<Task>>[];
var hasMore = true;
for (var i = 0; i < currentPage.value; i++) {
final lastPage = pages.lastOrNull;
final page = ref
.watch(tasksPageProvider(lastTask: lastPage?.lastOrNull, order: order));
if (page case AsyncValue(:final value?)) {
if (value.isNotEmpty) {
pages.add(value);
} else {
hasMore = false;
break;
}
} else {
break;
}
}
final loadMore = useCallback(() {
if (currentPage.value == pages.length && hasMore) {
currentPage.value++;
}
}, [currentPage.value, pages.length, hasMore]);
return (
tasks: pages.expand((page) => page).toList(),
loadMore: loadMore,
hasMore: hasMore,
);
}
これで Widget 側もすっきりします。
class TaskIndexScreen extends HookConsumerWidget {
const TaskIndexScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final order = useState(TaskOrder.asc);
final (
:tasks,
:loadMore,
:hasMore,
) = useTasks(ref: ref, order: order.value);
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
actions: [
TaskSortButton(
value: order.value,
onChanged: (value) => order.value = value,
),
],
),
body: TaskListView(
tasks: tasks,
onScrollToEnd: loadMore,
hasMore: hasMore,
),
);
}
}
今回の動作する完全なコードは GitHub にあります。