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);
}
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 loadcurrentPage
, 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 pagenextPage()
is used to calculate a new page index before fetchingloadNextPage()
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;
}
}
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;
}
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),
);
},
);
}
-
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),
);
},
),
);
}
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)