In this article, I’ll walk you through implementing infinite pagination with real-time updates using Firestore in a Flutter app. The goal is to load data in chunks from Firestore as the user scrolls through a list and to update the list in real-time whenever there’s a change in the Firestore collection.
Key Libraries Used:
- flutter_riverpod: For state management.
- cloud_firestore: For interacting with Firestore.
- visibility_detector: To detect when the user scrolls near the end of the list.
Step-by-Step Implementation
1. Setting Up Firebase
First, initialize Firebase in your main.dart file. Ensure you have configured Firebase correctly in your Flutter app by following the official Firebase documentation.
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'firebase_options.dart'; // Your generated Firebase options
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const InfiniteRealtimePagination(),
);
}
}
2. Creating the Data Model
Next, define a model class (DataModel) that represents the data structure in Firestore.
class DataModel {
final String description;
final String title;
final int numLikes;
final int numShares;
final String documentId;
DataModel({
required this.description,
required this.title,
required this.numLikes,
required this.numShares,
required this.documentId,
});
factory DataModel.fromJson(Map<String, dynamic> json) {
return DataModel(
description: json['description_cum'] ?? "",
title: json['title_cum'] ?? "",
numLikes: json['num_likes'] ?? 0,
numShares: json['num_shares'] ?? 0,
documentId: json['document_id'] ?? '',
);
}
}3. State Management with Riverpod
Use flutter_riverpod to manage the state of your application. Define a DataState class to handle the current state of your data.
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/data_model.dart';
class DataState {
final List<DataModel> dataModels;
final bool loading;
final bool noMoreData;
final DocumentSnapshot? lastDocument;
DataState({
required this.dataModels,
required this.loading,
required this.noMoreData,
required this.lastDocument,
});
DataState copyWith({
List<DataModel>? dataModels,
bool? loading,
bool? noMoreData,
DocumentSnapshot? lastDocument,
}) {
return DataState(
dataModels: dataModels ?? this.dataModels,
loading: loading ?? this.loading,
noMoreData: noMoreData ?? this.noMoreData,
lastDocument: lastDocument ?? this.lastDocument,
);
}
}
Create a DataStateNotifier to handle data fetching logic:
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/data_model.dart';
import '../states/data_state.dart';
final dataProviders =
AutoDisposeStateNotifierProvider<DataStateNotifier, DataState>(
(ref) {
final notifier = DataStateNotifier(
ref: ref,
fireStore: FirebaseFirestore.instance,
);
// This part is particularly useful when you want to temporarily retain the state of a provider to prevent it from being automatically disposed of when there are no active listeners.
// You can ignore this if you don't need to persist data temporarily on widget dispose.
ref.onCancel(() {
final link = ref.keepAlive();
final timer = Timer(const Duration(seconds: 15), () {
link.close();
});
ref.onDispose(timer.cancel);
});
return notifier;
},
);
class DataStateNotifier extends StateNotifier<DataState> {
final FirebaseFirestore fireStore;
final AutoDisposeStateNotifierProviderRef<DataStateNotifier, DataState> ref;
final List<List<DataModel>> allPagedResults = [];
final List<StreamSubscription<QuerySnapshot<Map<String, dynamic>>>> _subscriptions = [];
int pageLimit = 6;
DataStateNotifier({
required this.ref,
required this.fireStore,
}) : super(DataState(
dataModels: [],
loading: false,
noMoreData: false,
lastDocument: null,
)) {
// Initial data load.
loadMore();
// You can also set a listener here to for any changes and call the restart() function with new values to switch collection.
// listener() {
// restart();
// });
}
Future<void> loadMore() async {
if (state.loading || state.noMoreData) return;
await Future.delayed(Duration.zero);
state = state.copyWith(loading: true);
await fetchVideos();
}
Future<void> fetchVideos() async {
try {
String languageCode = 'en';
String country = 'in';
String collection = 'videos_${country}_$languageCode'; // Here country and langauge can be dynamic and can change depending on other providers.
Query<Map<String, dynamic>> query = fireStore
.collection(collection)
.limit(pageLimit)
.orderBy('created_on', descending: true);
if (state.lastDocument != null) {
query = query.startAfterDocument(state.lastDocument!);
}
// current page index
var currentRequestIndex = allPagedResults.length;
var snapshots = query.snapshots();
final subscription = snapshots.listen(
(QuerySnapshot<Map<String, dynamic>> querySnapshot) {
processData(
querySnapshot: querySnapshot,
currentRequestIndex: currentRequestIndex);
},
);
_subscriptions.add(subscription);
} catch (e) {
log('error from data_service: $e');
}
}
void processData({
required QuerySnapshot<Map<String, dynamic>> querySnapshot,
required int currentRequestIndex,
}) {
if (querySnapshot.docs.isNotEmpty) {
DocumentSnapshot? lastDocument;
final List<DataModel> videos = querySnapshot.docs.map((doc) {
final video = doc.data();
video['document_id'] = doc.id;
return DataModel.fromJson(video);
}).toList();
// Check if the page exists or not
var pageExists = currentRequestIndex < allPagedResults.length;
// If the page exists update the articles for that page
if (pageExists) {
allPagedResults[currentRequestIndex] = videos;
}
// If the page doesn't exist add the page data
else {
allPagedResults.add(videos);
}
// Concatenate the full list to be shown
var allVideos = allPagedResults.fold<List<DataModel>>(
[], (initialValue, pageItems) => initialValue..addAll(pageItems));
if (currentRequestIndex == allPagedResults.length - 1) {
lastDocument = querySnapshot.docs.last;
}
state = state.copyWith(
dataModels: allVideos,
lastDocument: lastDocument,
noMoreData: videos.length != pageLimit,
loading: false);
} else {
state = state.copyWith(noMoreData: false, loading: false);
}
}
// Always call this function to clear previous subscriptions before subscribing to a new collection.
Future<void> clearPreviousSubscription() async {
allPagedResults.clear();
List<Future<void>> cancelFutures = [];
for (var subscription in _subscriptions) {
cancelFutures.add(subscription.cancel());
}
// Wait for all cancel futures to complete
await Future.wait(cancelFutures);
_subscriptions.clear();
}
Future<void> clearData() async {
allPagedResults.clear();
state = state.copyWith(
loading: false,
firstLoad: true,
dataModels: [],
lastDocument: null,
noMoreData: false,
);
await clearPreviousSubscription();
}
void restart() async {
await clearData();
loadMore();
}
@override
void dispose() async {
await clearPreviousSubscription();
super.dispose();
}
}
4. Building the UI
Create a UI to display data with infinite scrolling. The InfiniteRealtimePagination widget uses CustomScrollView with SliverList for infinite scrolling.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../models/data_model.dart';
import '../states/data_state.dart';
class InfiniteRealtimePagination extends ConsumerWidget {
const InfiniteRealtimePagination({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataState = ref.watch(dataProviders);
return dataListBuilder(
loadMore: ref.read(dataProviders.notifier).loadMore,
dataState: dataState,
loader: const Center(
child: CircularProgressIndicator(),
),
errorWidget: const Center(
child: Icon(Icons.error_outline),
),
builder: (ctx, index, dataModel) {
return DataCard(
dataModel: dataModel,
);
},
);
}
}
typedef DataBuilder = Widget Function(
BuildContext context, int index, DataModel dataModel);
Widget dataListBuilder({
required DataState dataState,
required DataBuilder builder,
required Future<void> Function() loadMore,
required Widget loader,
required Widget errorWidget,
}) {
if (dataState.noMoreData && dataState.dataModels.isEmpty) {
return errorWidget;
}
final dataModels = dataState.dataModels;
return CustomScrollView(
slivers: [
SliverList.builder(
itemBuilder: (ctx, index) {
final dataModel = dataModels[index];
return builder(ctx, index, dataModel);
},
itemCount: dataModels.length,
),
if (dataState.loading || (!dataState.noMoreData && dataModels.isNotEmpty))
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverFillRemaining(
hasScrollBody: false,
child: VisibilityDetector(
key: const Key('_loader'),
onVisibilityChanged: (VisibilityInfo info) {
if (info.visibleFraction > 0) {
loadMore();
}
},
child: loader,
),
),
),
],
);
}
class DataCard extends StatelessWidget {
final DataModel dataModel;
const DataCard({super.key, required this.dataModel});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8.0),
),
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dataModel.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
Text(dataModel.description),
const SizedBox(height: 8),
Text("Likes: ${dataModel.numLikes}"),
Text("Shares: ${dataModel.numShares}"),
],
),
),
);
}
}
Conclusion
With this setup, you now have an app that can efficiently handle infinite scrolling with real-time updates using Firestore and Flutter. The Riverpod state management allows for clean and maintainable code while providing excellent performance. By using CustomScrollView and SliverList, your app will perform smoothly even with a large amount of data.
If you have any questions or need further clarifications, feel free to ask in the comments!
Happy Coding! 🚀