Firebase: Infinite Scroll Widget in Flutter

Author’s note: If you like this post, consider supporting at patreon.com/windlejacob12. I love writing, and sharing what I’ve learned. I’d love to spend the majority of my time doing it!

I’ve been working on a contact lately with a local startup in Johnson City, to implement infinite scrolling in their application. The codebase is entirely generated via FlutterFlow, and being generated by a low-code tool it’s a ball of spaghetti.

Their feed page as initially implemented would just pull in the latest 200 posts. This static query worked really well for them starting out. However, they went viral on social media (as one does) and we are now dealing with issues of scale. People are actually scrolling through the feed now! Also, additional screens that contain up to thousands of users are crashing as they all get pulled into memory from Firebase at once. Oodles of StreamBuilders, normally an excellent widget to choose in Flutter, were causing crashes as they all competed for what appeared to be a finite pool of connections to Firebase at once. iOS and Android would then just kill the app. So what were we to do?

Infinite Scroll

The idea of Infinite Scroll was floated, mimicking how other social media apps work by providing content in a paginated manner unbeknownst to the user. This would solve the problems that we were having with app crashes, while also making things more efficient. Most users aren’t going to sit and scroll on the app for a very long time. To support infinite scroll, queries that previously pulled in the entire collection of data would need to me modified. Data needed to be paginated. This page here details how to do it from the excellent Firebase documentation: https://firebase.google.com/docs/firestore/query-data/query-cursors

TL;DR - Query cursors allow you to “checkpoint” where you are in the stream of data from Firestore, and then return the next batch of data either starting at or ending at your checkpoint. Here’s the example that they gave on their site:

db.collection("cities").doc("SF").get().then(
  (documentSnapshot) {
    final biggerThanSf = db
        .collection("cities")
        .orderBy("population")
        .startAt([documentSnapshot]);
  },
  onError: (e) => print("Error: $e"),
);

This uses asynchronous programming in Dart, plus a DocumentSnapshot to serve as our query cursor. You retrieve SF, then you get all cities bigger than SF using .startAt towards the bottom of the inner function call.

Here’s the same code but with async/await instead.

try {
    var sfDoc = await db.collection("cities").doc("SF").get();
} catch(e) {
    print(e);
}

final biggerThanSf = await db
    .collection("cities")
    .orderBy("population")
    .startAt([sfDoc])
    .get();

This page, and these example queries gave me a great place to get started. The previous iteration of this screen had been through StreamBuilder widgets that were passed the query directly. The new iteration would do something a little more clever.

A Reusable Widget

Combining principles of both functional and object-oriented programming, and armed with the knowledge that everything is a widget in Flutter, it should be possible to represent our feed (and any other infinite scroll) as a widget. The Infinite Scroll would only need to know the following:

  1. How large should each page be?
  2. What is the “base query” that we are working with?
  3. How can we control the loading of a new page?
  4. How do we build the widgets that represent our list items?

With these questions, our class begins to take shape. Here’s an example skeleton class that attempts to answer all 4:

class InfiniteScrollFeed extends StatefulWidget {
    int pageSize; // 1.
    Query<Map<String,dynamic>> Function() makeFirebaseQuery; // 2.
    Paginator paginator; // 3.
    Widget Function(dynamic) itemBuilder; // 4.
    ScrollController controller; // 5.

    InfiniteScrollFeed(
        {this.pageSize, 
        this.makeFirebaseQuery,
        this.controller,
        this.itemBuilder}) {
            this.paginator = Paginator(pageSize: pageSize, queryFactory: makeFirebaseQuery);
    }
}
  1. A plain int can hold our page size, and is used later in constructing a paginator object.
  2. makeFirebaseQuery is a function that returns a firebase query, to ensure that we have fresh firebase query references with each invocation.
  3. Paginator is a TBD class that will handle the pagination of the query (and yield values)
  4. itemBuilder is a function describing how to build a widget from the return of the Paginator paging calls.
  5. ScrollController will control loading of our pages through scroll events later.

This borrows from functional programming via makeFirebaseQuery and itemBuilder. makeFirebaseQuery follows the factory pattern, in that it is a factory that will churn out Firebase Query objects. This function can return any firebase query that you would like, provided that it complies with the correct type of Query<Map<String, dynamic>>.

itemBuilder is a generic description of how to build a widget. This allows the InfiniteScrollFeed class to build a list of widgets from whatever data it retrieves from Firebase without knowing anything about the widget details. It just calls the function it was given to render widgets.

This higher level class of InfiniteScrollFeed will handle instrumenting our paginated query, and displaying our widgets, but now we must go deeper. We must look at pagination.

Paginator Widget

The Paginator widget shown above as a part of InfiniteScrollFeed (composition ftw), is where the magic happens. We can modify the example shown in the first section on query cursors to implement our new Paginator widget. The interface though, is very straightforward:

abstract class Paginator<T> {
  Future<List<T>> next();
}

The Paginator of type T should be able to return a Future<List<T>> containing whatever page of data we desire using the next() function. Often, our T will be a DocumentSnapshot from Firebase, but we can seriuosly return whatever we would like.

Now we can jump into the implementation of an actual FirebasePaginator class.

When I design a class, I always ask myself what the class needs to know at a bare minimum. The paginator class to get its job done doesn’t require much knowledge, here is what I determined to be the main needs:

  1. How do I make a query?
  2. How big should each page be?
  3. Where did my last query leave off?
/// [FirebasePaginator] will hold a reference to a Firebase stream, and will
/// paginate. Keeps track of state internally of the pagination
class FirebasePaginator implements Paginator {
  QueryDocumentSnapshot _lastVisible; // 3.

  Query<Map<String, dynamic>> Function() makeFirebaseQuery; // 1.
  int pageSize; // 2.

  FirebasePaginator({this.makeFirebaseQuery, this.pageSize});
}

1 above is the class member holding our query factory function. 2 is the pageSize variable. 3 is our internal field where we store our last read query cursor. 3 is the most important in implementing this class, because 3 will be passed as input to .startAt when we call the query.

So let’s build this up piece by piece:

/// [FirebasePaginator] will hold a reference to a Firebase stream, and will
/// paginate. Keeps track of state internally of the pagination
class FirebasePaginator implements Paginator {
  QueryDocumentSnapshot _lastVisible; // 3.

  Query<Map<String, dynamic>> Function() makeFirebaseQuery; // 1.
  int pageSize; // 2.

  FirebasePaginator({this.makeFirebaseQuery, this.pageSize});

  Future<QuerySnapshot<Map<String, dynamic>>> getDocumentSnapshots(void value) async {
    return _lastVisible == null
        ? await makeFirebaseQuery().limit(pageSize).get()
        : await makeFirebaseQuery()
            .startAfterDocument(_lastVisible)
            .limit(pageSize)
            .get();
  }
}

The addition of getDocumentSnapshots shows how we generally will be grabbing new data for our paginator widget. Check to see if _lastVisible exists, then if not, grab the first page of data (limited by pageSize). If _lastVisible does exist, then we use .startAfterDocument to tell FlutterFire to start our query after that document snapshot.

Now, the meat of the discussion, .next()

/// [FirebasePaginator] will hold a reference to a Firebase stream, and will
/// paginate. Keeps track of state internally of the pagination
class FirebasePaginator implements Paginator {
  QueryDocumentSnapshot _lastVisible; // 3.

  Query<Map<String, dynamic>> Function() makeFirebaseQuery; // 1.
  int pageSize; // 2.

  FirebasePaginator({this.makeFirebaseQuery, this.pageSize});

  Future<QuerySnapshot<Map<String, dynamic>>> getDocumentSnapshots(void value) async {
    return _lastVisible == null
        ? await makeFirebaseQuery().limit(pageSize).get()
        : await makeFirebaseQuery()
            .startAfterDocument(_lastVisible)
            .limit(pageSize)
            .get();
  }

  /// [next] get the next page of elements. Either the first one on first call, or
  /// subsequent pages.
  Future<List<QueryDocumentSnapshot<Map<String, dynamic>>>> next() async {
    // Get the documents from the stream.
    QuerySnapshot<Map<String, dynamic>> docSnapshots;
    try {
      // docSnapshots = await compute(getDocumentSnapshots, null);
      docSnapshots = await getDocumentSnapshots(null);
    } catch (e) {
      print(e);
      return [];
    }

    // Nothing left to do.
    if (docSnapshots.docs.isEmpty) {
      return [];
    }

    // Store the last visible document.
    _lastVisible = docSnapshots.docs[docSnapshots.docs.length - 1];
    return docSnapshots.docs;
  }
}

next() calls our Firebase query executor function, and returns the docs that we found. It has the additional side effect of setting the internal _lastVisible member, so that throughout the lifetime of requests to this class new pages are fetched until exhaustion.

The Paginator class has been built… let’s return to the scroll feed, which is markedly simpler with the pagination class.

Finishing InfiniteScrollFeed

Now, with the paginator implemented, there are two major pieces of functionality remaining in the class. We need to fetch elements with the paginator, tied to page scroll (and using the ScrollController). We also need to develop our actual method for rendering the feed when .build is called on the custom widget that we have built.

When dealing with streams, particularly creating listeners, in connection with StatefulWidgets the common pattern is to build our listeners into the initState function. The listeners can then do their work throughout the lifetime of the widget, and often call setState within their subscription handlers to update the state of the page. As input to the InfiniteScrollFeed widget, we passed a ScrollController to hopefully hook into scroll events on the page. The ScrollController gives us a stream of scroll events to listen to and react on.

The stream of scroll events will tell us when data is delivered on the stream where on the page we are. We want our pages of data to load both on initial widget load, and when we’ve scrolled to the bottom of the screen. Since InfiniteScrollFeed is a StatefulWidget, let’s create the State object required by InfiniteScrollFeed’s build method.

class InfiniteScrollFeed extends StatefulWidget {
    int pageSize; // 1.
    Query<Map<String,dynamic>> Function() makeFirebaseQuery; // 2.
    Paginator paginator; // 3.
    Widget Function(dynamic) itemBuilder; // 4.

    InfiniteScrollFeed(
        {this.pageSize, 
        this.makeFirebaseQuery,
        this.itemBuilder}) {
            this.paginator = Paginator(pageSize: pageSize, queryFactory: makeFirebaseQuery);
    }

    @override
    State<StatefulWidget> createState() => _InfiniteScrollFeedState();
}

class _InfiniteScrollFeedState extends State<InfiniteScrollFeed> {
  /// Internal paginator, range over a firebase query
  // FirebasePaginator _paginxator;

  bool _refreshing;
  bool _hasMoreElements;

  /// Our items we care about
  List<dynamic> elements;

  @override
  void initState() {}

  @overrid
  void dispose() {}

  @overrid
  Widget build(BuildContext context) {}

This class is for now, a skeleton. Let’s talk about a few different elements of the class here.

_refreshing is a state variable, that tells us whether or not we are pulling the next page of data.

_hasMoreElements is a flag, that tells us if the paginator has been exhausted.

elements holds all of the elements that are being returned by next() calls to the paginator.

initState is a lifecycle method, and here we will be setting up our state variables and stream listeners that interact with them.

dispose is a lifecycle method, and we will use this method to clean up the stream subscriptions once the widget is removed from the tree.

Let’s flesh out our initState() method. I’ll start ommitting additional code here for brevity.

  @override
  void initState() {
    // Create ane initially populate our widget.paginator.
    super.initState();
    elements = []; // 1.
    _refreshing = false;
    _hasMoreElements = true;
    widget.paginator.next().then((value) { // 2.
      setState(() => elements = value);
    }).then((_) {
      widget.controller.addListener(() { // 3.
        if (widget.controller.position.atEdge &&
            widget.controller.position.pixels != 0) {
          if (!_hasMoreElements) {
            return;
          }

          setState(() {
            _refreshing = true;
          });

          widget.controller.jumpTo(widget.controller.position.maxScrollExtent);

          widget.paginator.next().then((value) {
            setState(() {
              _hasMoreElements = value.isNotEmpty
              elements.addAll(value);
              _refreshing = false;
            });
          });
        }
      });
    });

1 is where we initialize our state variables, to ensure a smooth rendering the first time that Flutter draws this widget.

2 is where we fetch our first page of elements. We call .next() for the first time on the paginator, then use the return value after resolving the Future in a setState call. Once complete, the state variable elements contains the first page of data returned by the paginator.

3 is the scroll listener, that we use to then call .next() subsequently on the paginator with the next page of elements, adding them all to our internal list. The first part of that scroll listener checks to see if we are at the bottom edge of the screen. Next we ensure that our Paginator has not been exhausted by checking _hasMoreElements. Then we set our _refreshing state var to indicate that we are loading a new page. Finally at the end we call .next(). We update various state values depending on the return of our .next() call.

With initState built, the final piece is to write our build method:

  @override
  Widget build(BuildContext context) {
    if (elements.isEmpty) { // 1.
      return Text("Loading elements");
    }

    return ListView.builder(
        controller: widget.controller, // 2.
        scrollDirection: Axis.vertical,
        addAutomaticKeepAlives: true,
        shrinkWrap: true,
        itemCount: elements.length, // 3.
        itemBuilder: (context, index) { // 4.
            if (index == elements.length && _refreshing) {
                return Column(
                    children: [widget.itemBuilder(elements[index]), Text("Refreshing")],
                );
            }
            return widget.itemBuilder(elements[index]);
        },
        physics: ScrollPhysics(),
    );

The build() method of the widget ties everything together. We use our state vraiables in 1 to show a special widget if nothing has loaded yet. In 2, we pass our controller to an underlying ListView in order to receive our ScrollEvent stream. 3 shows how the elements are being used in this .builder call and 4 ties the ListView.builder.itemBuilder call in with our custom itemBuilder function. itemBuilder is called with each element in our elements state variable, rendering the widget on the page. This truly demonstrates the power of functional programming, in that the InfiniteScrollFeed widget does not need to know how it’s children are built.

Example calling this in code

Now, see how this class would be used.

 InfiniteScrollFeed(
    makeFirebaseQuery: () => FirebaseFirestore.instance
        .collection('posts')
        .orderBy('created_time', descending: true),
    itemBuilder: (post) {
        return FeedPost(data: post);
    },
    pageSize: 20,
    controller: _controller,
));

Simple right? A really nice interface, that will paginate the query it makes internally, to plug into existing code and gain efficiency!

Thanks for reading!

I’m going to put out a long form post once a week, so check in next week for updates.

 Share!

 
I run WindleWare! Feel free to reach out!

Subscribe for Exclusive Updates

* indicates required