Dart Tutorial

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 #

StyleRequestGood for
Page number?page=3&limit=20Simple lists
Offset?offset=40&limit=20The same idea, in items
Cursor?after=abc123&limit=20Feeds 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 #

ProblemCause and fix
The same page loads twiceNo loading guard
It never stops requestingEnd 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 errorThe error state is not shown or cleared
Duplicate itemsItems were added on the server while scrolling. Use cursor pagination, or remove duplicates by id
The list jumps to the top on each pageThe list widget is being recreated. Keep one ListView and only grow itemCount
The first page fills less than the screenNothing 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.

Practise in the playground Updated by Santosh Adhikari