Paginated list with pull-to-refresh
Infinite scrolling list that loads the next page near the bottom, supports pull-to-refresh and shows an inline retry row on error.
FlutterFeedback and empty statesflutterpaginationlistviewrefresh
Code
import 'package:flutter/material.dart';
typedef PageFetcher<T> = Future<List<T>> Function(int page);
class PagedList<T> extends StatefulWidget {
const PagedList({super.key, required this.fetch, required this.itemBuilder, this.pageSize = 20});
final PageFetcher<T> fetch;
final Widget Function(BuildContext, T) itemBuilder;
final int pageSize;
@override
State<PagedList<T>> createState() => _PagedListState<T>();
}
class _PagedListState<T> extends State<PagedList<T>> {
final _items = <T>[];
final _scroll = ScrollController();
int _page = 1;
bool _loading = false, _done = false;
Object? _error;
@override
void initState() {
super.initState();
_scroll.addListener(() {
if (_scroll.position.extentAfter < 400) _load();
});
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
if (_loading || _done) return;
setState(() { _loading = true; _error = null; });
try {
final rows = await widget.fetch(_page);
if (!mounted) return;
setState(() {
_items.addAll(rows);
_page++;
_done = rows.length < widget.pageSize; // short page means the end
});
} catch (e) {
if (mounted) setState(() => _error = e);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
_items.clear();
_page = 1;
_done = false;
await _load();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: _items.length + 1,
itemBuilder: (context, i) {
if (i < _items.length) return widget.itemBuilder(context, _items[i]);
if (_error != null) {
return ListTile(
title: const Text('Could not load more'),
trailing: TextButton(onPressed: _load, child: const Text('Retry')),
);
}
return _loading
? const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()))
: const SizedBox(height: 24);
},
),
);
}
}
How it works
What it solves
Listing screens must not download everything at once, yet users expect smooth scrolling, a manual refresh and a way to recover from a flaky connection. This widget bundles all three behind one small API.
How it works
The widget keeps its own list, page counter and flags. A scroll listener calls _load when fewer than 400 pixels remain below the viewport, and guard flags make sure only one request runs at a time. A page shorter than pageSize marks the list as finished, which avoids one extra empty request.
The last list slot is a footer: a spinner while loading, a retry row after a failure, or spacing otherwise. RefreshIndicator resets state and reloads the first page; AlwaysScrollableScrollPhysics lets the pull gesture work even when the content is short.
Adapting it
Pass any async function as fetch, for instance one that calls your repository with page and limit. For cursor APIs, store the cursor instead of the page number. Add an empty state when _items.isEmpty && _done, or wrap the list in AnimatedSwitcher for transitions.
Pitfalls
- A refresh that fails leaves the list empty; keep the old items until the new first page arrives if that matters.
- Two quick pulls can race, so the
_loadingguard also covers refresh. - Exact multiples of
pageSizecost one extra request returning nothing.
How to use it
Copy paged_list.dart into lib/widgets/. No packages.
PagedList<Post>(
fetch: (page) => repo.posts(page: page, limit: 20),
itemBuilder: (context, post) => ListTile(title: Text(post.title)),
)
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.