Infinite Scroll List using Flutter Firebase Firestore.

Infinite Scroll List using Flutter Firebase Firestore.

Reading data from firebase is expensive and if there is lot of data, only small portion of it would be visible to user. So why download all of it if we can use pagination method to achieve infinite scroll list.

This is our data in Firebase Firestore. Screenshot 2021-08-01 at 1.18.27 AM.png

We will start by creating a new flutter project. I will try to explain the steps required to achieve infinite scroll in flutter using firebase firestore.

  1. Create a data model class

    class ColorDetails {
    final String label;
    final int code;
    ColorDetails(this.code, this.label);
    
    factory ColorDetails.fromMap(Map<String, dynamic> json) {
     return ColorDetails(json['color_code'], json['color_label']);
    }
    
    Map toJson() {
     return {
       'color_code': code,
       'color_label': label,
     };
    }
    }
    
  2. Query to fetch data from Firestore

     Query _query = FirebaseFirestore.instance
             .collection("sample_data")
             .orderBy('color_label')
             .limit(PAGE_SIZE);
    

    Here PAGE_SIZE is the number of items we want in each page. For this tutorial, it's 30.

     static const PAGE_SIZE = 30;
    
  3. We will get the paged data from firebase in following way

    final List<ColorDetails> pagedData = await _query.get().then((value) {
       if (value.docs.isNotEmpty) {
         _lastDocument = value.docs.last;
       } else {
         _lastDocument = null;
       }
       return value.docs
           .map((e) => ColorDetails.fromMap(e.data() as Map<String, dynamic>))
           .toList();
     });
    

    We are saving the last document send which we will use while making call for next page. DocumentSnapshot? _lastDocument;

  4. Once we get the data, we can call setState

    setState(() {
       _data.addAll(pagedData);
       if (pagedData.length < PAGE_SIZE) {
         _allFetched = true;
       }
       _isLoading = false;
     });
    

    Following variables needs to be defined at top for this to work. Hence

    bool _allFetched = false;
    bool _isLoading = false;
    List<ColorDetails> _data = [];
    
  5. Now, we can show the data to the ListView

    @override
    Widget build(BuildContext context) {
     return Scaffold(
       appBar: AppBar(
         title: Text(widget.title),
       ),
       body: ListView.builder(
         itemBuilder: (context, index) {
           if (index == _data.length) {
             return Container(
               key: ValueKey('Loader'),
               width: double.infinity,
               height: 60,
               child: Center(
                 child: CircularProgressIndicator(),
               ),
             );
           }
           final item = _data[index];
           return ListTile(
             key: ValueKey(
               item,
             ),
             tileColor: Color(item.code | 0xFF000000),
             title: Text(
               item.label,
               style: TextStyle(color: Colors.white),
             ),
           );
         },
         itemCount: _data.length + (_allFetched ? 0 : 1),
       ),
     );
    }
    
  6. We will ScrollEndNotification NotificationListener and it's onNotification argument will be

    onNotification: (scrollEnd) {
           if (scrollEnd.metrics.atEdge && scrollEnd.metrics.pixels > 0) {
             _fetchFirebaseData();
           }
           return true;
         },
    
  7. The _fetchFirebaseData() is the method where we define our query, getting the data and calling setState. But the logic to get the data is still missing. So let's modify the query a little bit,

    Query _query = FirebaseFirestore.instance
         .collection("sample_data")
         .orderBy('color_label');
     if (_lastDocument != null) {
       _query = _query.startAfterDocument(_lastDocument!).limit(PAGE_SIZE);
     } else {
       _query = _query.limit(PAGE_SIZE);
     }
    
  8. Everything else remains same. You will see following output

ezgif.com-gif-maker.gif

  1. Here is the full code
#pubspec.yaml
name: flutter_firestore_infinite_scroll
description: A new Flutter project.

publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  firebase_core: "^1.4.0"
  cloud_firestore: "^2.4.0"

flutter:
  uses-material-design: true
//main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  final String title;
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const PAGE_SIZE = 30;

  bool _allFetched = false;
  bool _isLoading = false;
  List<ColorDetails> _data = [];
  DocumentSnapshot? _lastDocument;

  @override
  void initState() {
    super.initState();
    _fetchFirebaseData();
  }

  Future<void> _fetchFirebaseData() async {
    if (_isLoading) {
      return;
    }
    setState(() {
      _isLoading = true;
    });
    Query _query = FirebaseFirestore.instance
        .collection("sample_data")
        .orderBy('color_label');
    if (_lastDocument != null) {
      _query = _query.startAfterDocument(_lastDocument!).limit(PAGE_SIZE);
    } else {
      _query = _query.limit(PAGE_SIZE);
    }

    final List<ColorDetails> pagedData = await _query.get().then((value) {
      if (value.docs.isNotEmpty) {
        _lastDocument = value.docs.last;
      } else {
        _lastDocument = null;
      }
      return value.docs
          .map((e) => ColorDetails.fromMap(e.data() as Map<String, dynamic>))
          .toList();
    });

    setState(() {
      _data.addAll(pagedData);
      if (pagedData.length < PAGE_SIZE) {
        _allFetched = true;
      }
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: NotificationListener<ScrollEndNotification>(
        child: ListView.builder(
          itemBuilder: (context, index) {
            if (index == _data.length) {
              return Container(
                key: ValueKey('Loader'),
                width: double.infinity,
                height: 60,
                child: Center(
                  child: CircularProgressIndicator(),
                ),
              );
            }
            final item = _data[index];
            return ListTile(
              key: ValueKey(
                item,
              ),
              tileColor: Color(item.code | 0xFF000000),
              title: Text(
                item.label,
                style: TextStyle(color: Colors.white),
              ),
            );
          },
          itemCount: _data.length + (_allFetched ? 0 : 1),
        ),
        onNotification: (scrollEnd) {
          if (scrollEnd.metrics.atEdge && scrollEnd.metrics.pixels > 0) {
            _fetchFirebaseData();
          }
          return true;
        },
      ),
    );
  }
}

class ColorDetails {
  final String label;
  final int code;
  ColorDetails(this.code, this.label);

  factory ColorDetails.fromMap(Map<String, dynamic> json) {
    return ColorDetails(json['color_code'], json['color_label']);
  }

  Map toJson() {
    return {
      'color_code': code,
      'color_label': label,
    };
  }
}

This is my first article hence there might be few mistakes or presentation issue. Please let me know about them in the comment. I will try to improve and write more articles.