リモート開発メインのソフトウェア開発企業のエンジニアブログです

[Flutter] Riverpod で “宣言的に” 無限スクロールを実装する

前々回の記事 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 にあります。

← 前の投稿

VSCodeでTypeScriptのNode.jsを実行する

次の投稿 →

[Flutter] const にできないウィジェットのリビルドを抑制する

コメントを残す