DEV Community

Cover image for Implementing Infinite Scroll with Riverpod's AsyncNotifier
Dinko Marinac
Dinko Marinac

Posted on

Implementing Infinite Scroll with Riverpod's AsyncNotifier

Introduction

I recently had a task for a client on a project which uses Riverpod for state managment: Create an infinite scroll that can be very easily reused.

You might the tempted to answer: "there's a package for that".

You would be right, the most notable one is riverpod_infinite_scroll. However, it has not been updated for 16 months and uses a StateNotifier, which will be deprecated and requires additional setup to work with API calls.

Other solutions also rely on a rigid paging structure and go off the assumption that pages are always numbers, which is mostly true for the endpoints you consume but might be false for the UI. An example would be a banking app that displays transactions daily, weekly, or monthly. In that case, the page might not be an int, it might be a DateTime or a String.

Riverpod introduced AsyncNotifier into its catalog to simplify the most common use-case: async loading of the data. In this article, we will take advantage of that and learn how to abstract pagination which can be reused within the app.

The article is split into two parts: the code required on the data layer, and the code required on the UI layer.

Data Layer

A common question people ask about pagination is:

"How do I design the class that holds my state to keep track of the page I'm currently on, while simultaneously holding all the data?"

The answer is that you don't need the state class here. The UI doesn't need the information about which page is being loaded or how it is being done. The more of this logic is abstracted, the better.

Let's start by defining what the class that takes care of pagination needs to have. Every paginated response has 2 important variables: page and pageSize. We can ignore the page size since it can be static.

We also need to know what endpoint to call and when to load the next page. Additionally, if there is a need for non-integer paging, we have to know what is the initial value and how to compute the next value.

The result is the following interface:

abstract class PaginationController<T, I> {
  I get initialPage;

  late I currentPage = initialPage;

  FutureOr<List<T>> loadPage(I page);

  Future<void> loadNextPage();

  I nextPage(I currentPage);
}
Enter fullscreen mode Exit fullscreen mode

The interface takes in 2 generic parameters:

  • <T>, which represents type of data we are returning

  • <I>, which represent the type of the index we use for pages.

We are operating with 2 values:

  • initialPage, which will the initial page we want to load

  • currentPage, which is used to keep track of what page we are currently on

There are also 3 methods here:

  • loadPage() is used to describe how to fetch a new page

  • nextPage() is used to calculate a new page index before fetching

  • loadNextPage() is used from the UI when the user scrolls to the end

How to connect this with AsyncNotifier? The answer is by using a mixin, which enables every notifier to adopt this interface without inheritance. Composition is favored over inheritance here because inheritance creates strictly defined rules for a class, which is not mandatory.

mixin AsyncPaginationController<T, I> on AsyncNotifier<List<T>> implements PaginationController<T, I> {
  @override
  late I currentPage = initialPage;

  @override
  FutureOr<List<T>> build() async => loadPage(initialPage);

  @override
  Future<void> loadNextPage() async {
    state = const AsyncLoading();

    final newState = await AsyncValue.guard(() async {
      currentPage = nextPage(currentPage);
      final elements = await loadPage(currentPage);
      return [...?state.value, ...elements];
    });
    state = newState;
  }
}
Enter fullscreen mode Exit fullscreen mode

This mixin makes sure currentPage is tracked, the initialPage is loaded and the loadNextPage() appends new page elements, so the only thing left is to define the initial page, how to load the page, and how to calculate the next page index.

An example of an AsyncNotifier using this will look like (I'm using the MVVM architecture here):

class DataViewModel extends AsyncNotifier<List<Data>> with AsyncPaginationController<Data, int> {

  late final repository = ref.read(dataRepositoryProvider);

  @override
  final initialPage = 0;

  @override
  FutureOr<List<Data>> loadPage(int page) async {
    return repository.fetchData(page);
  }

  @override
  int nextPage(int currentPage) => currentPage + 1;
}
Enter fullscreen mode Exit fullscreen mode

UI layer

Now that the data layer is taken care of, it's the UI layer's turn. With an AsyncNotifier designed like this, the only thing the UI needs to do is call the loadNextPage() method once the scroll is detected.

There are 2 ways you can approach to detecting the end of the scroll behavior in a ListView.

  • ScrollController (you can use hooks here with Riverpod)
final ScrollController scrollController = ScrollController();

void _scrollListener() {
    if (scrollController.offset >= scrollController.position.maxScrollExtent &&
        !scrollController.position.outOfRange) {
      viewModel.loadNextPage();
    }
  }
  @override
  Widget build(BuildContext context) {
    final state = ref.watch(dataViewModelProvider);

    return switch (state) {
        AsyncData(:final value) => _list(context, value),
        AsyncError(:final error) => Text('Error: $error'),
        _ => const Center(child: CircularProgressIndicator()),
      };
  }

  Widget _list(BuildContext context, List<Data> data) {
    scrollController.addListener(() {
        _scrollListener();
      });

    return ListView.builder(
      itemCount: data.length,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text(data[index].name),
          subtitle: Text(data[index].description),
        );
      },
    );
  }
Enter fullscreen mode Exit fullscreen mode
  • NotificationListener widget

@override
  Widget build(BuildContext context) {
    final state = ref.watch(dataViewModelProvider);

    return switch (state) {
        AsyncData(:final value) => _list(context, value),
        AsyncError(:final error) => Text('Error: $error'),
        _ => const Center(child: CircularProgressIndicator()),
      };
  }

  Widget _list(BuildContext context, List<Data> data) {
    return NotificationListener(
      onNotification: (ScrollNotification scrollInfo) {
        if (scrollInfo is ScrollEndNotification &&
            scrollInfo.metrics.axisDirection == AxisDirection.down &&
            scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent) {
          viewModel.loadNextPage();
        }
        return true;
      },
      child: ListView.builder(
        itemCount: data.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(data[index].name),
            subtitle: Text(data[index].description),
          );
        },
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

I have chosen the NotificationListener since it's much less code and less code means less maintenance.

If you have enjoyed this short tutorial on how to make an infinite scrollable list with Riverpod, make sure to like and follow for more content like this.

Reposted from my blog.

Top comments (0)