Michael Debertol

Displaying Paginated APIs as Infinite Lists

FlutterRiverpod

While there are packages out there that provide such functionality, for the sake of this blog post we'll try to implement it ourselves, using only built-in Flutter widgets.

I'll be using riverpod for managing the state, but it should be possible to use something else as well.

Why Reimplement Something if There’s Already a Package?

Sometimes it's just easier. In my case the package mentioned above would have required changes to make it work for my usecase, meaning that I would have had to fork it possibly mantain that fork in the future.

Also, I was looking for something a bit simpler. If I implement something for myself, I can focus on the feature I need; a package has to cover many different usecases and will therefore be more complex to use.

What Features Do We Need?

In this post we'll only explore very basic options:

How To Manage State?

As already mentioned, we're using riverpod, so our data will come from a Provider. It's very convenient to use FutureProvider.family for our usecase.

Let's take a look at our provider:

final itemProvider =  FutureProvider.family<List<Item>, int>(
(ref, page) {
return fetchItems(page);
},
);

You can add more features to this provider, like refresh functionality or debouncing.

Using ListView.custom

Since we want to display a List of items, we'll probably want to look at ListView. Since we don't know all items beforehand, let's take a closer look at ListView.builder. Reading through its documentation we'll soon discover an issue: To display a list with a limited amount of items we have to provide itemCount - but it turns out we don't know the number of items at the beginning! This is however only a minor problem: We'll have to use the lesser known ListView.custom constructor, which allows us to signal that there are no more items by returning null from the item builder.

The basic code will look like this:

ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) {
// TODO: implement item builder
},
),
)

Implementing The Item Builder

First, we have to calculate the page index and the index of the item in the page.

final pageIndex = index ~/ pageSize;
final itemIndex = index % pageSize;

Then, to get the corresponding page, we can read our itemProvider:

final page = ref.watch(itemProvider(pageIndex));

page is an AsyncValue<List<Item>> object, and it can eiter be loading, contain an error, or contain the items we wanted. Let's handle all those different cases:

return page.map(
loading: (_) => LoadingTile(),
error: (error) {
if (itemIndex == 0) {
return Text('Error: $error');
} else {
return null;
}
},
data: (data) {
final items = data.value;
if (items.length <= itemIndex) {
return null;
}
return ItemTile(item: items[itemIndex]);
},
)

LoadingTile will show a shimmer effect while the data is loading. Error handling should look a bit different in a real app, possibly offering a retry button and showing the error in a more user-friendly way.

Full Example

That's already it! I left out some details above that are not relevant for what I wanted to show, but for the sake of completeness I'll provide a full example below (I've highlighted the most important parts):

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shimmer/shimmer.dart';

class Item {
final String title;

Item(this.title);
}

const pageSize = 10;

Future<List<Item>> fetchItems(int page) async {
return Future.delayed(
const Duration(seconds: 1),
() => List.generate(
10,
(i) => Item(
(page * 10 + i).toString(),
),
),
);
}

final itemProvider = FutureProvider.family<List<Item>, int>(
(ref, page) {
return fetchItems(page);
},
);

void main() {
runApp(
const ProviderScope(
child: MaterialApp(
home: HomePage(),
),
),
);
}

class LoadingTile extends StatelessWidget {
const LoadingTile({Key? key}) : super(key: key);


Widget build(BuildContext context) {
return ListTile(
title: Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.white70.withOpacity(0.8),
child: Container(
width: 90,
height: 14,
color: Colors.white,
),
),
);
}
}

class ItemTile extends StatelessWidget {
final Item item;
const ItemTile({Key? key, required this.item}) : super(key: key);


Widget build(BuildContext context) {
return ListTile(
title: Text(item.title),
);
}
}

class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);


Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text("Infinite Scroll Sample"),
),
body: ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) {
final pageIndex = index ~/ pageSize;
final itemIndex = index % pageSize;
final page = ref.watch(itemProvider(pageIndex));
return page.map(
loading: (_) => const LoadingTile(),
error: (error) {
if (itemIndex == 0) {
return Text('Error: $error');
} else {
return null;
}
},
data: (data) {
final items = data.value;
if (items.length <= itemIndex) {
return null;
}
return ItemTile(item: items[itemIndex]);
},
);
},
),
),
);
}
}