Flutter Lesson 49 of 83 4 min read
Pagination and Infinite Scroll in Flutter
Load long lists page by page in Flutter: infinite scroll with ListView.builder, loading indicators, end detection and error handling.
On this page
An API with ten thousand products does not send them all at once. It sends a page, perhaps twenty items, and you ask for the next when the user needs it. Loading more as the user approaches the bottom is called infinite scroll.
How APIs paginate #
| Style | Request | Good for |
|---|---|---|
| Page number | ?page=3&limit=20 | Simple lists |
| Offset | ?offset=40&limit=20 | The same idea, in items |
| Cursor | ?after=abc123&limit=20 | Feeds that change while you scroll |
The examples use page numbers. The structure is the same for the others.
What to keep track of #
- The items loaded so far.
- The next page to request.
- Whether a request is in progress, so you do not send two.
- Whether there are more pages.
- Whether the last request failed.
A paginated model #
import 'package:flutter/foundation.dart';
class PagedList<T> extends ChangeNotifier {
PagedList(this._fetchPage, {this.pageSize = 20});
final Future<List<T>> Function(int page, int pageSize) _fetchPage;
final int pageSize;
final List<T> items = [];
bool loading = false;
bool hasMore = true;
String? error;
int _page = 1;
Future<void> loadMore() async {
if (loading || !hasMore) return; // guard against duplicate requests
loading = true;
error = null;
notifyListeners();
try {
final page = await _fetchPage(_page, pageSize);
items.addAll(page);
_page++;
if (page.length < pageSize) hasMore = false; // a short page means the end
} catch (e) {
error = 'Could not load more items';
} finally {
loading = false;
notifyListeners();
}
}
Future<void> refresh() async {
items.clear();
_page = 1;
hasMore = true;
await loadMore();
}
}
The list #
The trick is to give the list one extra row at the end. When that row is built, the user has reached the bottom, so it both shows the spinner and triggers the next load.
// Not standalone: paste the PagedList class from above into the same file.
import 'package:flutter/material.dart';
Future<List<String>> fakeFetch(int page, int pageSize) async {
await Future<void>.delayed(const Duration(seconds: 1));
if (page > 5) return []; // five pages in total
return List.generate(pageSize, (i) => 'Item ${(page - 1) * pageSize + i + 1}');
}
void main() => runApp(const MaterialApp(home: FeedPage()));
class FeedPage extends StatefulWidget {
const FeedPage({super.key});
@override
State<FeedPage> createState() => _FeedPageState();
}
class _FeedPageState extends State<FeedPage> {
final _list = PagedList<String>(fakeFetch);
@override
void initState() {
super.initState();
_list.loadMore();
}
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Feed')),
body: ListenableBuilder(
listenable: _list,
builder: (context, child) {
final items = _list.items;
if (items.isEmpty && _list.loading) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: _list.refresh,
child: ListView.builder(
itemCount: items.length + 1, // one extra row for the footer
itemBuilder: (context, index) {
if (index < items.length) {
return ListTile(title: Text(items[index]));
}
return _Footer(list: _list);
},
),
);
},
),
);
}
}
class _Footer extends StatelessWidget {
const _Footer({required this.list});
final PagedList<String> list;
@override
Widget build(BuildContext context) {
if (list.error != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(list.error!),
TextButton(onPressed: list.loadMore, child: const Text('Try again')),
],
),
);
}
if (!list.hasMore) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('You have reached the end')),
);
}
// Reaching this row means the user is at the bottom: load the next page.
WidgetsBinding.instance.addPostFrameCallback((_) => list.loadMore());
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
}
addPostFrameCallback delays the call until the frame has finished, because changing state during build is not allowed.
Loading before the user reaches the end #
For smoother scrolling, start loading a little early with a ScrollController.
final _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(() {
final position = _controller.position;
if (position.pixels >= position.maxScrollExtent - 400) {
_list.loadMore(); // within 400 pixels of the bottom
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// ListView.builder(controller: _controller, ...)
The guard at the top of loadMore makes it safe to call many times.
Things that go wrong #
| Problem | Cause and fix |
|---|---|
| The same page loads twice | No loading guard |
| It never stops requesting | End of data not detected. Compare the page length with the page size, or use the API’s hasNext field |
| The spinner never goes away after an error | The error state is not shown or cleared |
| Duplicate items | Items were added on the server while scrolling. Use cursor pagination, or remove duplicates by id |
| The list jumps to the top on each page | The list widget is being recreated. Keep one ListView and only grow itemCount |
| The first page fills less than the screen | Nothing triggers the next load. The footer-row approach handles this on its own |
A “Load more” button #
Infinite scroll suits feeds. For results the user is searching through, such as products or orders, a button gives more control and lets them reach the footer of the page.
A package #
infinite_scroll_pagination provides the state handling, indicators and error views for lists, grids and slivers. It is worth using once you understand what it does.
Try it yourself #
Use https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10 as the source. Build an infinite list of post titles with pull to refresh, an end-of-list message, and a retry option when a page fails to load.