Many applications have a lazy data loading feature. For example, it loads the first 20 data and if a user scrolls down to the bottom of the list, the application loads the next 20 data. I will show how to implement it in this article.
The following video shows the behavior.
Data list to fetch
In this article, we don’t access a database for brevity. We use instead just a function that returns Future. We prepare 200 items and a function that returns desired number of items.
List<int> _data = [for (var i = 0; i < 200; i++) i];
Future<List<int>> _fetch(int count) {
return Future.delayed(
Duration(seconds: 2),
() => _data.where((element) => element < count).toList(),
);
}
The reason why we use Future type is to emulate database access or REST API. Those basically return Future type because it takes a while to complete the work. The delay time is 2 seconds to make it easy to see the loading icon.
We set 50, 100, 150 and 200 to the count parameter to load next data. If we need to fetch data by SQL, set a value to LIMIT
cause. See here for the SQLite.
Show the list by FutureBuilder
Let’s start with FutureBuilder. We need it because _fetch
function returns Future type.
class LoadingNext extends StatefulWidget {
@override
_LoadingNext createState() => _LoadingNext();
}
class _LoadingNext extends State<LoadingNext> {
int _count = 50;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text("Loading next data"),
),
body: _createBody(context),
),
);
}
Widget _createBody(BuildContext context) {
return FutureBuilder(
future: _fetch(_count),
builder: (BuildContext context, AsyncSnapshot<List<int>?> snapshot) {
final data = snapshot.data;
if (data == null) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
},
);
}
}
It shows a progress indicator while _fetch
function is processing.
Detect the current position by ScrollController
We need to fetch the next data when a user scrolls down to the bottom of the list view. The important thing is when to start the process. In this example, we will start fetching the next data if the current position reaches 80% of the view.
To get the scroll position, we can set ScrollController to ListView.controller
.
final controller = ScrollController();
controller.addListener(() {
final position =
controller.offset / controller.position.maxScrollExtent;
if (position >= 0.8) {
setState(() {
_count += 50;
});
}
});
return ListView.builder(
controller: controller, // set controller here
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
Did you recognize that data is shown to 149 after the first loading when reaching the bottom of the list view? The code above is not good because setState
is called many times.
setState
should not be called while loading the next data. Let’s add conditions to improve it.
final controller = ScrollController();
controller.addListener(() {
final position =
controller.offset / controller.position.maxScrollExtent;
if (position >= 0.8) {
// Add conditions here
if (data.length == _count && _count < _data.length) {
setState(() {
_count += 50;
});
}
}
});
return ListView.builder(
controller: controller,
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
When setState is called, build method is called again for the parent widget. FutureBuilder.builder passes the old data while loading the new data. Namely, snapshot.data.length
, which is data.length
above, is 50 for the first time whereas the _count
is 100.
Once the loading process is completed, snapshot.data.length
is 100 and position is 0.5.
Don’t forget to call controller.dispose()
if you implement the logic in the same class.
Extract the ScrollController logic into a Widget
If we need to have the same feature for the different lists, we need to implement it multiple times. Let’s extract the logic to make it easier.
class ScrollListener extends StatefulWidget {
final Widget Function(BuildContext, ScrollController) builder;
final VoidCallback loadNext;
final double threshold;
ScrollListener({
required this.threshold,
required this.builder,
required this.loadNext,
});
@override
_ScrollListener createState() => _ScrollListener();
}
class _ScrollListener extends State<ScrollListener> {
ScrollController _controller = ScrollController();
@override
void initState() {
super.initState();
_controller.addListener(() {
final rate = _controller.offset / _controller.position.maxScrollExtent;
if (widget.threshold <= rate) {
widget.loadNext();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _controller);
}
}
We can replace the previous code with the following code.
return ScrollListener(
threshold: 0.8,
builder: (context, controller) {
return ListView.builder(
controller: controller,
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
},
loadNext: () {
if (data.length == _count && _count < _data.length) {
setState(() {
_count += 50;
});
}
},
);
Show CircularProgressIndicator while loading the next data
The last thing that we want to do is to show a progress indicator while loading the next data. Let’s show CircularProgressIndicator
. The following code is what I implemented at first.
return ScrollListener(
threshold: 0.8,
builder: (context, controller) {
final listView = ListView.builder(
controller: controller,
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
if (data.length != _count) {
return Stack(
children: [
listView,
Center(child: CircularProgressIndicator()),
],
);
} else {
return listView;
}
},
loadNext: () {
if (data.length == _count && _count < _data.length) {
setState(() {
_count += 50;
});
}
},
);
But this code doesn’t work as expected! The position goes back to the top when start loading the next data.
I don’t know why the controller.position
is initialized but it can be solved if we always return the same widget tree.
return ScrollListener(
threshold: 0.8,
builder: (context, controller) {
final listView = ListView.builder(
controller: controller,
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return ListTile(title: Text("Item number - ${data[index]}"));
},
);
return Stack(
children: [
listView,
Opacity(
opacity: data.length != _count ? 1 : 0,
child: Center(child: CircularProgressIndicator()),
),
],
);
},
loadNext: () {
if (data.length == _count && _count < _data.length) {
setState(() {
_count += 50;
});
}
},
);
Stack Widget allows us to show a widget on a widget. The bottom widget is shown at the top.
Opacity Widget controls transparency. If the opacity is 0, the widget is fully transparent which means invisible. It can be replaceable with Visibility in this case.
Visibility(
visible: data.length != _count,
child: Center(child: CircularProgressIndicator()),
),
If we want to show the translucent progress indicator, use Opacity.
Comments