In real-world Flutter applications, we often load dynamic data from servers or databases instead of using hardcode/dummy data as we often see in online examples.
For instance, you have an e-commerce app and need to load products from Rest API from the server side. If you only have 10 or 20 products, it will be easy to fetch them all with just one GET request. However, if your platform has thousands of products or even millions of products (e.g. a B2C platform), you have to implement pagination to reduce the burden on the server and reduce latency and improve the app’s performance as well.
This article (which was recently updated to work well with Flutter 3 and newer) walks you through a complete example of creating a ListView with pagination in Flutter.
Table of Contents
The Strategy
In the beginning, we send a get request and load only a fixed number of results (choose a number that makes sense in your case) and display them in a ListView.
When the user scrolls to the bottom of the ListView, we send another GET request and fetch another fixed number of results, and so on.
To know when the user scrolls near the bottom of a ListView, we will create a ScrollController and attach it to the ListView. When ScrollController.position.extendAfter has a value less than a certain level (200 or 300 is good to go), we will call a function that sends a new GET request.
You can find more information about ScrollController in the official docs. If you feel the words are too boring and confusing, just jump straight to the practical example below.
App Preview
This example will load some dummy blog posts from a public Pest API endpoint provided by jsonplaceholder.typicode.com (special thanks to the author for making an awesome tool for testing purposes):
https://jsonplaceholder.typicode.com/posts?_page=[page-number]&_limit=[some-number]
When the app launches for the first time, we will fetch the first 20 posts. Next, every time we scroll near the bottom of the ListView, a function named _loadMore will be called and this function will load 20 more posts.
After all the posts from the API have been loaded, a message will appear letting us know that even if we scroll down, nothing will happen.
Progress indicators will also appear while we are sending requests to the server.
A demo is worth more than thousands of words:
The Code
1. Create a new Flutter project.
2. To send http requests with ease, we will the http package. Install it by running:
dart pub add http
3. Remove all the default code in your main.dart and add the following:
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
// Remove the debug banner
debugShowCheckedModeBanner: false,
title: 'Kindacode.com',
theme: ThemeData(
primarySwatch: Colors.pink,
),
home: const HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
// We will fetch data from this Rest api
final _baseUrl = 'https://jsonplaceholder.typicode.com/posts';
// At the beginning, we fetch the first 20 posts
int _page = 0;
// you can change this value to fetch more or less posts per page (10, 15, 5, etc)
final int _limit = 20;
// There is next page or not
bool _hasNextPage = true;
// Used to display loading indicators when _firstLoad function is running
bool _isFirstLoadRunning = false;
// Used to display loading indicators when _loadMore function is running
bool _isLoadMoreRunning = false;
// This holds the posts fetched from the server
List _posts = [];
// This function will be called when the app launches (see the initState function)
void _firstLoad() async {
setState(() {
_isFirstLoadRunning = true;
});
try {
final res =
await http.get(Uri.parse("$_baseUrl?_page=$_page&_limit=$_limit"));
setState(() {
_posts = json.decode(res.body);
});
} catch (err) {
if (kDebugMode) {
print('Something went wrong');
}
}
setState(() {
_isFirstLoadRunning = false;
});
}
// This function will be triggered whenver the user scroll
// to near the bottom of the list view
void _loadMore() async {
if (_hasNextPage == true &&
_isFirstLoadRunning == false &&
_isLoadMoreRunning == false &&
_controller.position.extentAfter < 300) {
setState(() {
_isLoadMoreRunning = true; // Display a progress indicator at the bottom
});
_page += 1; // Increase _page by 1
try {
final res =
await http.get(Uri.parse("$_baseUrl?_page=$_page&_limit=$_limit"));
final List fetchedPosts = json.decode(res.body);
if (fetchedPosts.isNotEmpty) {
setState(() {
_posts.addAll(fetchedPosts);
});
} else {
// This means there is no more data
// and therefore, we will not send another GET request
setState(() {
_hasNextPage = false;
});
}
} catch (err) {
if (kDebugMode) {
print('Something went wrong!');
}
}
setState(() {
_isLoadMoreRunning = false;
});
}
}
// The controller for the ListView
late ScrollController _controller;
@override
void initState() {
super.initState();
_firstLoad();
_controller = ScrollController()..addListener(_loadMore);
}
@override
void dispose() {
_controller.removeListener(_loadMore);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Kindacode.com'),
),
body: _isFirstLoadRunning
? const Center(
child: const CircularProgressIndicator(),
)
: Column(
children: [
Expanded(
child: ListView.builder(
controller: _controller,
itemCount: _posts.length,
itemBuilder: (_, index) => Card(
margin: const EdgeInsets.symmetric(
vertical: 8, horizontal: 10),
child: ListTile(
title: Text(_posts[index]['title']),
subtitle: Text(_posts[index]['body']),
),
),
),
),
// when the _loadMore function is running
if (_isLoadMoreRunning == true)
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 40),
child: Center(
child: CircularProgressIndicator(),
),
),
// When nothing else to load
if (_hasNextPage == false)
Container(
padding: const EdgeInsets.only(top: 30, bottom: 40),
color: Colors.amber,
child: const Center(
child: Text('You have fetched all of the content'),
),
),
],
),
);
}
}
Note: In many cases, the server side gives us information about the total number of results it has or the total number of pages it has. In these scenarios, you can still do the same as the example above or modify the code a bit to check whether _loadMore should be called or not.
Conclusion
We’ve examined an end-to-end example of implementing a ListView with pagination in a Flutter app. When you use your own API, there may be some differences in the URL structure, for example:
https://www.example.com/api/products?currentPage=1&perPage=10
Or:
https://www.kindacode.com/api/users?start=10&limit=20&order=desc
Make sure you understand the API you’re going to get data from. If not, talk to your backend developers and ask them to provide the necessary information.
If you’d like to learn more new stuff about Flutter, take a look at the following articles:
- Flutter & SQLite: CRUD Example
- Flutter and Firestore Database: CRUD example (null safety)
- Using GetX (Get) for Navigation and Routing in Flutter
- Using GetX (Get) for State Management in Flutter
- Flutter FutureBuilder example (null safety)
- Flutter StreamBuilder examples (null safety)
You can also take a tour around our Flutter topic page, or Dart topic page for the latest tutorials and examples.