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:
- How large should each page be?
- What is the “base query” that we are working with?
- How can we control the loading of a new page?
- 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);
}
}
- A plain
int
can hold our page size, and is used later in constructing a paginator object. makeFirebaseQuery
is a function that returns a firebase query, to ensure that we have fresh firebase query references with each invocation.Paginator
is a TBD class that will handle the pagination of the query (and yield values)itemBuilder
is a function describing how to build a widget from the return of thePaginator
paging calls.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:
- How do I make a query?
- How big should each page be?
- 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.