Infinite Scrolling Pagination in Flutter

Learn how to implement infinite scrolling pagination (also known as lazy loading) in Flutter using the Infinite Scroll Pagination package. By Edson Bueno.

Login to leave a rating/review
Download materials
Save for later

Forget about the gold at the end of the rainbow. Have you ever wondered what’s at the end of your Instagram feed? Has anyone ever scrolled that far? You could find the password to a secret Swiss bank account, the key to immortality or the answer to the meaning of life. Oh, the possibilities.

Today you’ll not only solve that mystery, but will also master this very same infinite scrolling technique. The technique has many names, including infinite scrolling pagination, endless scrolling pagination, auto-pagination, lazy loading pagination, continuous scroll pagination and progressive loading pagination.

By the end of this tutorial, you’ll know:

  • What infinite scrolling pagination is
  • What its alternatives are
  • Why it’s such a big deal
  • How to go from a non-paginated app to a paginated one
  • How to take that knowledge to any project you work on
Note: This article won’t cover the basics of widgets and networking. For that, please refer to the linked tutorials.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

In case you didn’t know, has an app for Android and iOS called (drum roll, please): raywenderlich. The thing is, that one is for videos. Today you’ll be working on a low budget version for articles: readwenderlich!

Unzip the downloaded file and open Android Studio 4.1 or later. You can use Visual Studio Code instead, but you might need to tweak some instructions to follow along.

Click on Open an existing Android Studio project and choose the starter folder from your unzipped download.

Now, download your dependencies by double-clicking pubspec.yaml on the left-side panel (1) and then clicking Pub get (2) at the top of your screen.

Instructions on downloading pubspec.yaml dependencies.

Finally, build and run. If everything went OK, you should see something like this:

Starter version of the sample project.

The starter project only fetches the first 20 items for the filtering and sorting options you set. For example, if you filter by Flutter and sort by Most Popular, you’ll see the 20 most popular Flutter articles.

Diving Into the Code

There are many files in the project, but the ones you need to be familiar with are:

  1. lib/ui/preferences/list_preferences.dart: A plain Dart class gathering all the filtering and sorting options the user selected.
  2. lib/ui/list/article_list_view.dart: A widget that receives ListPreferences and uses its information to fetch and display a list of articles. The image below highlights the exact part of the screen controlled by this widget.

ArticleListView widget highlighted on the sample project.

The starter project’s state management approach is setState, because that’s the neutral ground for all Flutter developers. But what you’ll learn here applies to any strategy.

Throughout the tutorial, you won’t change ArticleListView. Instead, you’ll write a substitute for it from scratch.

Rather than a top 20, you want readwenderlich to be a complete catalog of all the articles on For that, you’ll rely on infinite scrolling pagination.

Understanding Pagination

Two guys walk into a bar… just kidding! It’s actually one person, and it’s a woman. You’ll call her the user, while the bar is your app.

The night is just beginning, and you, the bartender, know the user will consume lots of data drinks. What you don’t yet know is how many.

You’re committed to providing the best customer experience, so you start wondering what would be the optimal way of serving your user. You then grab some nearby napkins and start writing out your options:

Napkin sketch of an eager way of serving drinks.

If you think of drinks like batches of items in a list — the so-called pages — the method above is how non-paginated apps work. It’s no surprise that there aren’t bars that serve like this, but unfortunately, this is how most apps work. Which leads you to another option:

Napkin sketch of the conventional lazy way of serving drinks.

If you’re familiar with the above, don’t worry. It’s not only because you’ve been going a lot to bars, but also because that’s how good old web-like pagination works:

Page selection bar from Google Search.

Every time you finish a page, you have to manually ask for another. Good, but still not perfect. It’d be better if you could automate the process.

Automating Pagination

With no luck on your quest for the holy grail of drink service, you’re considering settling for the conventional way… until the customer comes to you with a fantastic idea:

Woman asking a bartender to hand her a drink any time she's lacking one.

That sounds amazing! But you’re cautious, and you don’t want to make a decision before planning everything out on paper a napkin:

Napkin sketch of an automated way of serving drinks.

It’s a done deal! Thank goodness; that was your last napkin!

Luckily, someone did science a favor and recorded the experiment:

Bartender actively serving drinks every time the customer is without one.

This process is the same as infinite scrolling pagination: As the user scrolls down your screen, you watch what’s going on and fetch more data before she hits the bottom of the screen, giving her the smooth experience of scrolling an infinite list.

Look, all the cool kids apps are doing it:

Twitter, Facebook and Instagram using infinite scrolling pagination.

So now that you know what you’ll be doing, it’s time to roll up your sleeves.

Creating a Paginated ArticleListView

Start off by creating a new file inside lib/ui/list called paged_article_list_view.dart. Put the following inside the file:

import 'package:flutter/material.dart';
import 'package:readwenderlich/data/repository.dart';
import 'package:readwenderlich/entities/article.dart';
import 'package:readwenderlich/ui/exception_indicators/empty_list_indicator.dart';
import 'package:readwenderlich/ui/exception_indicators/error_indicator.dart';
import 'package:readwenderlich/ui/list/article_list_item.dart';
import 'package:readwenderlich/ui/preferences/list_preferences.dart';

// 1
class PagedArticleListView extends StatefulWidget {
  const PagedArticleListView({
    // 2
    @required this.repository,
    // 3
    Key key,
  })  : assert(repository != null),
        super(key: key);

  final Repository repository;
  final ListPreferences listPreferences;

  _PagedArticleListViewState createState() => _PagedArticleListViewState();

class _PagedArticleListViewState extends State<PagedArticleListView> {
  // 4
  ListPreferences get _listPreferences => widget.listPreferences;
  // TODO: Instantiate a PagingController.

  // 5
  Widget build(BuildContext context) => const Placeholder();

Here’s what you just did:

  1. This is the widget that will replace the old non-paginated list onscreen. This is your paginated version of ArticleListView.
  2. You’re asking a Repository instance from the enclosing widget. Repository comes with the starter project and helps you communicate with the remote API.
  3. Similarly, you’re now asking a ListPreferences instance from the enclosing widget.
  4. You’re creating a computed property to help you access the ListPreferences received in the previous step without the need to type widget.listPreferences every time.
  5. The Placeholder fills the screen with a giant X while you don’t have the paginated list widget figured out yet. You’ll get back to this shortly.

Now you need to be able to visualize what you just created.

Swapping List Widgets

Swap the old ArticleListView for your fresh PagedArticleListView. For that, open lib/ui/list/article_list_screen.dart and add an import to your new file at the top:

import 'package:readwenderlich/ui/list/paged_article_list_view.dart';

Since you’re already working with the imports, take the opportunity to do some cleaning by removing the soon-to-be unused ArticleListView import:

import 'package:readwenderlich/ui/list/article_list_view.dart';

Jump to the build() and replace the Scaffold‘s body property with:

body: PagedArticleListView(
  repository: Provider.of<Repository>(context),
  listPreferences: _listPreferences,

You’re obtaining a Repository instance from Provider, your dependency injection system for this project.

That’s it! You can delete the now obsolete lib/ui/list/article_list_view.dart.

Build and run. You should see your Placeholder in action:

Intermediate version of the sample project, displaying a placeholder instead of the list.

Engineering Infinite Scrolling Pagination

In the whole drink service situation above, you looked at infinite scrolling pagination from a product perspective. Now, put your developer glasses on, divide your goal into pieces and examine what it takes to conquer it:

  • Watch the user’s scroll position so that you can fetch other pages in advance.
  • Keep track of and transition between every possible status in your list.
  • Flow diagram of all possible listing statuses.

  • Keep the user posted by displaying indicators for each different status.
  • Screenshots of every possible pagination status.

  • Make a solution that’s reusable in different screens, possibly using other layouts. One example of this is grids. Ideally, this solution should also be portable to different projects with other state management approaches.

Sounds like hard work? It doesn’t have to be. These issues are already addressed by the Infinite Scroll Pagination package, which will be your companion for this article. In the next section, you’ll take a closer look at this package.

Getting to Know the Package

Warm up by opening pubspec.yaml and replacing # TODO: Add infinite_scroll_pagination dependency here. with infinite_scroll_pagination: ^3.1.0:

    sdk: flutter
  infinite_scroll_pagination: ^3.1.0

Download your newest dependency by clicking on Pub get in the Flutter commands bar at the top of your screen.

The Infinite Scroll Pagination package makes your job as easy as stealing candy from a baby, shooting fish in a barrel or assembling a three-piece jigsaw puzzle. Speaking of the latter, here’s your first piece:

PagingController represented as a puzzle piece.

PagingController is a controller for paged widgets. It’s responsible for holding the current state of the pagination and request pages from its listeners whenever needed.

If you’ve worked with Flutter’s TextEditingController or ScrollController, for example, you’ll feel at home with PagingController.

Instantiating a PagingController

Back to lib/ui/list/paged_article_list_view.dart, add an import to the new library at the top of the file:

import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';

Now, replace // TODO: Instantiate a PagingController. with:

// 1
final _pagingController = PagingController<int, Article>(
  // 2
  firstPageKey: 1,

void initState() {
  // 3
  _pagingController.addPageRequestListener((pageKey) {

Future<void> _fetchPage(int pageKey) async {
  // TODO: Implement the function's body.

void dispose() {
  // 4

Here’s a step-by-step explanation of what the code above does:

  1. When instantiating a PagingController, you need to specify two generic types. In your code, they are:
    • int: This is the type your endpoint uses to identify pages. For the API, that’s the page number. For other APIs, instead of a page number, that could be a String token or the number of items to offset. Due to this diversity of pagination strategies, the package calls these identifiers page keys.
    • Article: This is the type that models your list items.
  2. Remember the int you specified as a generic type in the previous step? Now you need to provide its initial value by using the firstPageKey parameter. For the API, page keys start at 1, but other APIs might start at 0.
  3. This is how you register a callback to listen for new page requests.
  4. Don’t forget to dispose() your controller.

Fetching Pages

Your _fetchPage() implementation doesn’t have much use as it is right now. Fix this by replacing the entire function with:

Future<void> _fetchPage(int pageKey) async {
  try {
    final newPage = await widget.repository.getArticleListPage(
      number: pageKey,
      size: 8,
      // 1
      filteredPlatformIds: _listPreferences?.filteredPlatformIds,
      filteredDifficulties: _listPreferences?.filteredDifficulties,
      filteredCategoryIds: _listPreferences?.filteredCategoryIds,
      sortMethod: _listPreferences?.sortMethod,

    final previouslyFetchedItemsCount =
        // 2
        _pagingController.itemList?.length ?? 0;

    final isLastPage = newPage.isLastPage(previouslyFetchedItemsCount);
    final newItems = newPage.itemList;

    if (isLastPage) {
      // 3
    } else {
      final nextPageKey = pageKey + 1;
      _pagingController.appendPage(newItems, nextPageKey);
  } catch (error) {
    // 4
    _pagingController.error = error;

This is where all the magic happens:

  1. You’re forwarding the current filtering and sorting options to the repository.
  2. itemList is a property of PagingController. It holds all items loaded so far. You’re using the ? conditional property access because itemList initial value is null.
  3. Once you have your new items, let the controller know by calling appendPage() or appendLastPage() on it.
  4. If an error occurred, supply it to the controller’s error property.

Build and run to make sure you haven’t introduced any errors. Don’t expect any visual or functional changes.

Intermediate version of the sample project, displaying a placeholder instead of the list.

Using a Paginated ListView

Before you move on to the build(), there’s something you need to know:

PagingController and PagedListView represented as puzzle pieces.

The second piece is exactly what its name suggests: a paginated version of a regular ListView. And as the illustration shows, it’s in there that you’ll fit in your controller.

Still on lib/ui/list/paged_article_list_view.dart, replace the old build() with:

Widget build(BuildContext context) =>
    // 1
      onRefresh: () => Future.sync(
        // 2
        () => _pagingController.refresh(),
      // 3
      child: PagedListView.separated(
        // 4
        pagingController: _pagingController,
        padding: const EdgeInsets.all(16),
        separatorBuilder: (context, index) => const SizedBox(
          height: 16,

Here’s what’s going on:

  1. Wrapping scrollable widgets with Flutter’s RefreshIndicator empowers a feature known as swipe to refresh. The user can use this to refresh the list by pulling it down from the top.
  2. PagingController defines refresh(), a function for refreshing its data. You’re wrapping the refresh() call in a Future, because that’s how the onRefresh parameter from the RefreshIndicator expects it.
  3. Like the good old ListView you already know, PagedListView has an alternative separated() constructor for adding separators between your list items.
  4. You’re connecting your puzzle pieces.

Building List Items

After all this, you suspect something might be wrong — after all, what’s going to build your list items?

The good Sherlock Holmes that you are, you investigate by hovering your magnifying glass — also known as a mouse — over PagedListView:

Android Studio warning about a missing required parameter.

Well done, detective. You found the missing puzzle piece!

PagingController, PagedListView and PagedChildBuilderDelegate represented as puzzle pieces.

Now, it’s time to put them all together!

Creating a Builder Delegate

For the final touch, fill that same gap in code by specifying this new parameter to your PagedListView, in the same level as pagingController, padding and separatorBuilder:

builderDelegate: PagedChildBuilderDelegate<Article>(
  itemBuilder: (context, article, index) => ArticleListItem(
    article: article,
  firstPageErrorIndicatorBuilder: (context) => ErrorIndicator(
    error: _pagingController.error,
    onTryAgain: () => _pagingController.refresh(),
  noItemsFoundIndicatorBuilder: (context) => EmptyListIndicator(),

PagedChildBuilderDelegate is a collection of builders for every widget involved in infinite scrolling pagination.

Although you’ve only specified three parameters in your code, knowing all seven of them might save you in the future:

  • itemBuilder: This builds your list items. It’s the only required parameter; all others have defaults.
  • firstPageErrorIndicatorBuilder: This builds a widget that informs the user the occurrence of an error fetching the first page. In this scenario, you won’t yet have any list items loaded, so you’ll want this widget to fill the entire space. Make sure you call refresh() on your controller if you decide to add a retry button to your indicator — take another look at the previous code snippet and you’ll see it.
  • newPageErrorIndicatorBuilder: This also indicates an error, but for subsequent page requests. As you already have some items loaded, the widget you build here appears at the bottom of your list. If you add a retry button to your indicator, make sure you call retryLastRequest() on your controller when the user taps your button.
  • firstPageProgressIndicatorBuilder: This builds the widget that’ll show while loading the first page. You won’t yet have any items loaded, so you’ll want this widget to fill the entire space.
  • newPageProgressIndicatorBuilder: This is similar to the previous builder, but it shows while loading subsequent pages. As you already have some items loaded, the widget you build here appears at the bottom of your list.
  • noItemsFoundIndicatorBuilder: What if your API successfully returns an empty list? Technically that’s not an error, and it’s usually associated with too many filter options selected. The widget you build here covers this “zero items” scenario.
  • noMoreItemsIndicatorBuilder: Here it is, the gold at the end of the rainbow! This is where you optionally build a widget to display when the user finally reaches the end of your list. Mystery solved!

Build and run. Try turning off your device’s connection and using swipe to refresh as shown in the GIF below. This will help you test both RefreshIndicator and the custom error indicator widget.

Sample project with filters not working properly.

Hmm, there’s a bug in your app. Apparently that wasn’t the final touch after all. Try figuring out what’s wrong. If you’re stuck, click the Reveal button below to find out what it is.

[spoiler title=”Filters Bug”]
Applying filters doesn’t seem to be taking any effect.

Applying Filters

You know what they say: Be suspicious if your code works on the first try. Well, yours didn’t, so you’re probably doing something right!

Here’s what’s causing the bug: When the user applies filters, ArticleListScreen rebuilds your PagedArticleListView with a brand-new ListPreferences, but no one warned your PagingController about the change.

The place for solving this is lib/ui/list/paged_article_list_view.dart. Inside _PagedArticleListViewState, add the following function override:

void didUpdateWidget(PagedArticleListView oldWidget) {
  if (oldWidget.listPreferences != widget.listPreferences) {

This is what the code above is saying: Whenever the widget associated with this State subclass updates, if the listPreferences also changed, refresh the _pagingController by calling refresh() on it.

Mission accomplished. Time to build and run readwenderlich for the last time and allow the wonder of infinite scrolling pagination to amaze you. You did it!

Completed sample project.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

Now for the coolest part: The Infinite Scroll Pagination package comes with replacement parts for your middle piece. Choose whichever suits your next layout the best.

All classes from the Infinite Scroll Pagination package represented as jigsaw puzzle pieces.

You can find more details on these extra pieces in the package’s cookbook.

Here’s a suggestion: The endpoint you used to fetch your articles accepts search queries. How about using that to add search support to readwenderlich?

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!