In this article, we’ll dive into the world of Flutter, exploring how to implement enhanced infinite scroll pagination using the StreamBuilder widget, and adding a loading indicator to make the user experience even better.
Introduction
Infinite scroll pagination is a common design pattern that allows users to scroll through an app’s content continuously, loading more data as they reach the end of the currently visible content. Flutter, Google’s UI toolkit for building natively compiled applications, provides several powerful widgets to streamline this process.
One such widget is StreamBuilder, which facilitates real-time data updates and UI rendering. We’ll leverage StreamBuilder to fetch and display paginated data seamlessly while enhancing the user experience with loading indicators.

Getting Started
Before we dive into the code, ensure you have Flutter installed. If you haven’t already, set up a new Flutter project or use an existing one.
The Challenge: Pagination with StreamBuilder
Imagine you have a RESTful API serving a list of items, and you want to display these items in your Flutter app while supporting infinite scroll pagination. The challenge is to fetch additional data when the user reaches the end of the current list and continue to do so as long as there’s more data available.
Solution: Enhanced Infinite Scroll Pagination
Here’s how we can tackle this challenge and enhance it with loading indicators:
- Create a StreamController: We’ll use a StreamController to manage the stream of data we’ll display.
- Implement the Fetch Function: Define a function that fetches data from your API. This function should handle pagination by accepting parameters like page number and page size.
- Add Data to the Stream: As you fetch data, add it to the stream. Be careful not to overwrite previous data; instead, append the new data to the existing list.
- StreamBuilder: Use the StreamBuilder widget to listen to the data stream. When new data is added to the stream, StreamBuilder rebuilds the UI with the updated content.
- Scroll Controller: Add a ScrollController to your scrollable widget (e.g., ListView or GridView). Listen to scroll events, and when the user reaches the end, trigger the fetch function to load more data.
- Loading Indicator: While new data is being fetched and loaded, display a loading indicator, such as a circular progress indicator, below the current content.
Code Example
Here’s a simplified example that illustrates the method:
main.dart
import 'package:flutter/material.dart';
import 'package:fluttertest/fetch_data.dart';
import 'package:fluttertest/infinite_scroll_pagination.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const InfiniteScroll(),
);
}
}
class InfiniteScroll extends StatelessWidget {
const InfiniteScroll({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final ScrollController scrollController = ScrollController();
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
Expanded(
child: InfiniteScrollPagination(
scrollController: scrollController,
fetchDataFunction: fetchData,
),
),
],
),
),
);
}
}
infinite_scroll_pagination.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluttertest/item.dart';
typedef FetchDataFunction = Future<List<dynamic>> Function(
int currentPage, int pageSize);
class InfiniteScrollPagination extends StatefulWidget {
final ScrollController scrollController;
final FetchDataFunction fetchDataFunction;
const InfiniteScrollPagination(
{Key? key,
required this.scrollController,
required this.fetchDataFunction})
: super(key: key);
@override
State<InfiniteScrollPagination> createState() =>
_InfiniteScrollPaginationState();
}
class _InfiniteScrollPaginationState extends State<InfiniteScrollPagination> {
final StreamController<List<dynamic>> _dataStreamController =
StreamController<List<dynamic>>();
Stream<List<dynamic>> get dataStream => _dataStreamController.stream;
final List<dynamic> _currentItems = [];
int _currentPage = 1;
final int _pageSize = 60;
late final ScrollController _scrollController;
bool _isFetchingData = false;
Future<void> _fetchPaginatedData() async {
if (_isFetchingData) {
// Avoid fetching new data while already fetching
return;
}
try {
_isFetchingData = true;
setState(() {});
final startTime = DateTime.now();
final items = await widget.fetchDataFunction(
_currentPage,
_pageSize,
);
_currentItems.addAll(items);
// Add the updated list to the stream without overwriting the previous data
final endTime = DateTime.now();
final timeDifference =
endTime.millisecondsSinceEpoch - startTime.millisecondsSinceEpoch;
if (timeDifference < 2000) {
// Delay for 2 seconds if the time taken by the API request is less then 2 seconds
await Future.delayed(const Duration(milliseconds: 2000));
}
_dataStreamController.add(_currentItems);
_currentPage++;
} catch (e) {
_dataStreamController.addError(e);
} finally {
// Set to false when data fetching is complete
_isFetchingData = false;
setState(() {});
}
}
@override
void initState() {
// TODO: implement initState
super.initState();
_scrollController = widget.scrollController;
_fetchPaginatedData();
_scrollController.addListener(() {
_scrollController.addListener(() {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (currentScroll == maxScroll) {
// When the last item is fully visible, load the next page.
_fetchPaginatedData();
}
});
});
}
@override
Widget build(BuildContext context) {
return StreamBuilder<List<dynamic>>(
stream: dataStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// Display a loading indicator
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
// Handle errors
return Center(child: Text('Error: ${snapshot.error}'));
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
// Display a message when there is no data
return const Center(child: Text('No data available.'));
} else {
// Display the paginated data
final items = snapshot.data;
return ListView(
controller: _scrollController,
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items!.length,
itemBuilder: (context, index) {
return Item(img: items[index].toString());
},
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3),
),
if (_isFetchingData)
const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
)),
],
);
}
},
);
}
@override
void dispose() {
_dataStreamController.close();
//we do not have control cover the _scrollController so it should not be disposed here
// _scrollController.dispose();
super.dispose();
}
}
fetch_data.dart
import 'dart:convert';
import 'package:http/http.dart';
Future<List<String>> fetchData(int currentPage, int pageSize) async {
final Uri uri = Uri.parse("https://api.pexels.com/v1/search");
const String API_KEY = "Your_Api_Key"; // Replace with your API key
final Map<String, String> headers = {
"Authorization": API_KEY,
};
final Map<String, String> params = {
"query": 'cat',
"per_page": pageSize.toString(),
"page": currentPage.toString(),
};
final Uri finalUri = uri.replace(queryParameters: params);
final response = await get(
finalUri,
headers: headers,
);
if (response.statusCode == 200) {
final jsonResponse = jsonDecode(response.body);
final photos = jsonResponse["photos"] as List<dynamic>;
final items = <String>[];
for (final photo in photos) {
items.add(photo["src"]["medium"]);
}
return items;
} else {
throw Exception('Failed to fetch data');
}
}
Conclusion
Enhancing your infinite scroll pagination with loading indicators ensures a smoother and more enjoyable user experience. Users can clearly see when new data is being fetched, making your app feel more responsive and user-friendly.
By converting a StreamBuilder into a powerful pagination tool and adding loading indicators, you can keep users engaged and provide them with fresh content without interruptions. Still there are better option for this but give this approach a try in your next Flutter project, and watch your app’s user experience soar to new heights!