Flutter: Creating a ListView that loads one page at a time
Update: The method followed in this article is very simple, but might not be the best or most efficient. There are other methods that you can find online that do not require you to put a ListView inside another ListView. Feel free to keep reading, however, and see for yourself whether you prefer this method or something else.
—
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:
- to fetch entries asynchronously, from some remote server
- 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 outerListView
is aListView
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
. EachListTile
corresponds to a fetched entry. - We set the
primary
property tofalse
. This tells flutter that thisListView
is not the primary scrolling target. Because the parentListView
is the actual scrolling target, not this one - We set
shrinkWrap
property totrue
. This tells flutter that thisListView
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 theAlign
widget totopCenter
, so that the indicator shows at the top of theSizedBox
.
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.
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:
- Replace the
FutureBuilder
in your code withKeepAliveFutureBuilder
. - 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 aStatefulWidget
whose State extends the State class with theAutomaticKeepAliveClientMixin
. - It implements the
wantKeppAlive
getter, and makes it simply return true, to denote to theListView
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
.