Implementing Infinite Scroll with Riverpod's AsyncNotifier
Tags: flutter, riverpod, ui, mobile
Published: 2024-02-19 09:10:29

## 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](https://pub.dev/packages/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: 

```dart
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);
}
```

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.

```dart
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;
  }
}
```

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):

```dart
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;
}
```

## 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)

```dart
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),
        );
      },
    );
  }
```

* `NotificationListener` widget

```dart

@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),
          );
        },
      ),
    );
  }
```

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](https://dinkomarinac.dev/implementing-infinite-scroll-with-riverpods-asyncnotifier).