Flutter: Creating a ListView that loads one page at a time

Flutter provides the awesome ListView.builder; a ListView constructor that allows us to create a lazy-loaded list, where entries are created only when we scroll down to them. This constructor accepts as an input a callback named itemBuilder, and calls this callback whenever it wants to create a new item as a result of scrolling:

ListView.builder(
itemBuilder: (context, index) {
return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text('product $index'),
subtitle: Text('price: ${Random().nextInt(100)} USD'),
);
}
)

This will create an infinite scrolling list of items, which are only loaded when we scroll down to them.

However, when we want to use this in a real-life scenario, things get more complicated. We would probably want:

  1. to fetch entries asynchronously, from some remote server
  2. to fetch entries by batch (also known as page). That is, to fetch each, say 20, entries together, not one by one.

In this tutorial, we are going to discuss how to do exactly that! We will start by learning how to fetch ListView entries asynchronously using FutureBuilder. Then, we will see how we can fetch these entries one page at a time.

tl;dr

If you’re in a hurry, and don’t have the time to go through the tutorial, you can check out my package, flutter_pagewise, which achieves exactly what I will explain in this tutorial. I do recommend, though, to go through the article, as it discusses several flutter concepts that you might find useful.

Fetching entries asynchronously

We want to fetch entries asynchronously from a remote server, but only when we scroll down to them. To do that, we will use the aforementionedListView.builder constructor, along with FutureBuilder.

Let’s assume that we have a function _fetchEntry that looks like:

_fetchEntry(int index) async {
await Future.delayed(Duration(milliseconds: 500));

return {
'name': 'product $index',
'price': Random().nextInt(100)
};
}
  • This function emulates a server that gives you the name and price of the product at the given index, and takes half a second to do that

Then we can call this function in our ListView.builder:

ListView.builder(
itemBuilder: (context, index) {
return FutureBuilder(
future: this._fetchEntry(index),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return CircularProgressIndicator();
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {

var productInfo = snapshot.data;

return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text(productInfo['name']),
subtitle:
Text('price: ${productInfo['price']}USD'),
);
}
}
},
);
}
)
  • The FutureBuilder is a widget that awaits a given future, and uses its builder function to build different widgets depending on the status of the future
  • When the future has not been called, or has been called but hasn’t returned its result yet. We are showing a CircularProgressIndicator.
  • When the future is done, we check to see if it returned with an error. If so, we show the text of the error
  • Otherwise, we show a ListTile that displays the name and price returned from the future

And that’s it, when you start the app, you can see the entries getting loaded, then shown on screen, and as you scroll down, the same will happen for more and more entries

Not bad, but we have a few problems to fix here:

The loader looks really ugly

We can see that the progress indicator is too wide, because it is trying to fit all the available width. To solve that, we can simply wrap our indicator with an Align widget, and set its alignment property to center:

return Align(
alignment: Alignment.center,
child: CircularProgressIndicator()
);

This will give us nicer-looking progress indicators

But the main problem, which is the main focus of this article, is to load each 20 of those at a time, instead of loading them one-by-one.

Loading entries one page at a time

To do that, we can create a ListView of ListViews! Each child of our ListView.builder, will be a ListView that contains the page’s entries.

Let’s assume that our _fetchEntry function does not fetch single entries anymore, rather full pages! so we will call it _fetchPage instead, and it will look like:

_fetchPage(int pageNumber, int pageSize) async {
await Future.delayed(Duration(seconds: 1));

return List.generate(pageSize, (index) {
return {
'name': 'product $index of page $pageNumber',
'price': Random().nextInt(100)
};
});
}
  • The function emulates fetching a page from the server, by generating a list of pageSize entries.
  • This emulated server takes 1 second to return your request

Our ListView.builder will now look like:

ListView.builder(
itemBuilder: (context, pageNumber) {
return FutureBuilder(
future: this._fetchPage(pageNumber, 20),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Align(
alignment: Alignment.center,
child: CircularProgressIndicator()
);
case ConnectionState.done:
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {

var pageData = snapshot.data;

return this._buildPage(pageData);
}
}
},
);
}
)
  • Our ListView.builder now calls _fetchPage in its constructor instead of _fetchEntry, and specifies that we want the page size to be 20 entries
  • When the page is ready, instead of returning a ListTile as we used to do, we are calling a function named _buildPage , and providing it with the page data that we fetched from the server

But what does _buildPage do? It builds an inner ListView to represent the page!

Widget _buildPage(List page) {
return ListView(
shrinkWrap: true,
primary: false,
children: page.map((productInfo) {
return ListTile(
leading: Icon(Icons.shopping_cart),
title: Text(productInfo['name']),
subtitle: Text('price: ${productInfo['price']}USD'),
);
}).toList()
);
}
  • The function returns a ListView. That is, each child of the outer ListView is a ListView that holds the entries of the page.
  • We use the map method to transform the list of data that we have into a list of ListTiles. Each ListTile corresponds to a fetched entry.
  • We set the primary property to false. This tells flutter that this ListView is not the primary scrolling target. Because the parent ListView is the actual scrolling target, not this one
  • We set shrinkWrap property to true. This tells flutter that this ListView should not try to expand infinitely in the vertical direction. That is also the job of the parent.

With that, the following will happen:

That… is not what we expected! It did not really load one page, rather about fifteen!

But why? The problem is with the progress indicator. When we first start, progress indicators are shown, and these progress indicators are small! So the ListView.builder will decide that it can fit about 15 entries in the view, and will proceed accordingly showing us 15 pages.

So what can we do? We can, again, wrap our progress indicator with one more widget

SizedBox(
height: MediaQuery.of(context).size.height * 2,
child: Align(
alignment: Alignment.topCenter,
child: CircularProgressIndicator()
),
);
  • The SizedBox is a widget that allows us to give it fixed dimensions
  • We give it a height that is 2 times as big as the viewport’s height. That way, the builder will only load one entry at a time. To get the viewport’s height, we used the MediaQuery.of function, which gives us information about the dimensions of the current media.
  • We also changed the alignment property of the Align widget to topCenter, so that the indicator shows at the top of the SizedBox.

Now, we are doing much better:

And that’s it, we’re almost there!

One last problem

What’s left? Well, this implementation still has a small bug, it’s a bit hard to see at first, but if you scroll down a few pages, and then scroll back up, you would notice that the scrolling is kind of broken. It keeps throwing you around in a bizarre way.

Stuck at page 6

The explanation of this behavior is a bit complicated: ListView, and ScrollViews in general, tend to dispose of the children that are not currently visible on the screen. When we try to scroll back to the child, the child is reinitialized from scratch. But in this case, our child is a FutureBuilder; re-initializing it creates a progress indicator again just for a part of a second, then creates the page once again. This confuses the scrolling mechanism, throwing us around in non-deterministic ways.

How to solve it?

One way to solve this is to make sure that the progress indicator has the exact same size of the page, but in most cases, that is not too practical. So, we will resort to a method that is less efficient, but that will solve our problems; we will prevent ListView from disposing of the children. In order to do that, we need to wrap each child — that is, each FutureBuilder, with an AutomaticKeepAliveClientMixin. This mixin makes the children ask their parent to keep them alive even when off-screen, which will solve our problem. So:

  1. Replace the FutureBuilder in your code with KeepAliveFutureBuilder.
  2. Create the KeepAliveFutureBuilder widget:
class KeepAliveFutureBuilder extends StatefulWidget {

final Future future;
final AsyncWidgetBuilder builder;

KeepAliveFutureBuilder({
this.future,
this.builder
});

@override
_KeepAliveFutureBuilderState createState() => _KeepAliveFutureBuilderState();
}

class _KeepAliveFutureBuilderState extends State<KeepAliveFutureBuilder> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: widget.future,
builder: widget.builder,
);
}

@override
bool get wantKeepAlive => true;
}
  • This widget is just a wrapper around the FutureBuilder. It is a StatefulWidget whose State extends the State class with the AutomaticKeepAliveClientMixin.
  • It implements the wantKeppAlive getter, and makes it simply return true, to denote to the ListView that we want this child to be kept alive.

And that’s it! This time we’re really done! We have created a ListView that loads one page at a time. It is not the most efficient one, but it solves our problem.

Let’s face it though, this code is too much boilerplate. I do recommend to abstract this whole logic in a widget of its own. Or -shamless plug- to use my flutter_pagewise package, which provides you with extendable, elegant widgets that solve this problem for both ListView and GridView.