The Pollyn desktop and mobile applications have a variety of scroll views that users can interact with including lists of referral codes, a friends list, and notifications. While Flutter comes with out of the box widgets for building scrollable views with ListView and GridView, we had some additional features we wanted from our scrollable widgets that motivated us to make our own:
If you do a quick search for infinite list libraries on pub.dev
, you will find a number of high-quality Flutter libraries already out there for working with infinite list views. What we found was that none of them quite met the set of features we wanted and decided it would be useful and interesting to go ahead and build our own. If your applications need a similar set of features or you are just curious, then let’s get right to it and take a look at Endless, our new infinite scroll view library.
The most common data source for infinite lists is generally some sort of paginated API. The library comes with two pagination widgets EndlessPaginationListView
and EndlessPaginationGridView
for working with this type of data. Let’s take a look at a basic example:
import 'package:flutter/material.dart';
import 'package:endless/endless.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Endless pagination list view')),
body: EndlessPaginationListView<String>(
loadMore: (pageIndex) async => {...},
paginationDelegate: EndlessPaginationDelegate(
pageSize: 5,
maxPages: 10,
),
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item);
},
),
),
);
}
}
In this example, we create an EndlessPaginationListView
with 3 configuration options:
loadMore
returns fewer than pageSize
items.When the user scrolls passed the threshold for loading more items as specified by the paginationDelegate.extentAfterFactor
, the list view will call loadMore
and request more data. Working with grids has the same API as lists, with an additional gridDelegate
specification:
import 'package:flutter/material.dart';
import 'package:endless/endless.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Endless pagination grid view')),
body: EndlessPaginationGridView<String>(
loadMore: (pageIndex) async => {...},
paginationDelegate: EndlessPaginationDelegate(
pageSize: 5,
maxPages: 10,
),
// The only difference between the basic list and grid view is that a grid specifies its delegate such as how many items
// to put in the cross axis.
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item);
},
),
),
);
}
}
The library uses a CustomScrollView widget under the hood to build the elements of the list. This makes it easy to support common list elements like headers and footers. The library supports a set of builder functions for these elements of scrollable widgets as shown below:
Header -> headerBuilder
Items -> itemBuilder
Empty state -> emptyBuilder
Loading spinner -> loadingBuilder
Load more widget (such as a TextButton) -> loadMoreBuilder
Footer -> footerBuilder
The following example uses header, footer and load more builders:
class _MyHomePageState extends State<MyHomePage> {
final pager = ExampleItemPager();
final controller = EndlessPaginationController<ExampleItem>();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: EndlessPaginationListView<ExampleItem>(
loadMore: (pageIndex) async => pager.nextBatch(),
paginationDelegate: EndlessPaginationDelegate(
pageSize: 5,
),
controller: controller,
headerBuilder: (context) {
return const Text("I'm a header!");
},
footerBuilder: (context) {
return const Text("I'm a footer!");
},
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item.title);
},
loadMoreBuilder: (context) => TextButton(
child: const Text('load more'),
onPressed: () => controller.loadMore(),
),
),
),
);
}
}
As we can see in the demo, while the height of the items is less than the available space, the button specified in the loadMoreBuilder
can be used to request more data. Once the height of the items exceeds
the available space, the view becomes scrollable and infinitely loads more data when the scroll threshold is reached at the bottom.
In the previous example, our list view had a fixed header. What if we only wanted to show our header after we’ve loaded items? Endless scroll views use the StateProperty pattern found in Flutter Material’s core widgets such as TextButton.
The Material UI libray uses this pattern to let consumers of core widgets like TextButton
style it differently when it is in one more states like hover or pressed. The basic example from the docs looks like this:
TextButton(
style: ButtonStyle(
// Use the color green as the background color for all button states.
backgroundColor: MaterialStateProperty.all<Color>(Colors.green),
),
);
TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
// The state property passes all the current states the button is in
// so that the button style can be customized.
(Set<MaterialState> states) {
// Lighten the button color when it is in the pressed state.
if (states.contains(MaterialState.pressed))
return Theme.of(context).colorScheme.primary.withOpacity(0.5);
return null;
},
),
),
);
We use this same pattern to support customizing scroll views based on their current states. The possible states are defined as the following:
enum EndlessState {
/// Whether the endless scroll view currently has no items.
empty,
/// Whether the endless scroll view is currently loading items.
loading,
/// Whether the endless scroll view has finished loading all items. Determined when loading
/// items returns fewer items than the expected size.
done
}
We can then check the current states of the scroll view to customize our header:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Endless pagination list view')),
body: EndlessPaginationListView<String>(
loadMore: (pageIndex) async => {...},
paginationDelegate: EndlessPaginationDelegate(
pageSize: 5,
maxPages: 10,
),
// Each builder has a corresponding state property builder for state-dependent UI.
headerBuilderState: EndlessStateProperty.resolveWith((states) {
if (states.contains(EndlessState.empty)) {
return null;
}
return Container(
color: Colors.blue,
child: const Text('Header'),
);
}),
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item);
},
),
),
);
}
}
The full list of endless state property helpers are given below:
EndlessStateProperty.all
EndlessStateProperty.loading
EndlessStateProperty.empty
EndlessStateProperty.done
EndlessStateProperty.never
EndlessStateProperty.resolveWith
Some builder functions have default state property behaviors. The emptyBuilder parameter for example is automatically wrapped in an emptyStateBuilder defined to only be built if the scroll view is empty and not loading as shown below:
EndlessStateProperty<Widget?> resolveEmptyBuilderToStateProperty(
Builder<Widget>? builder,
) {
return _resolveBuilderToStateProperty<Widget>(
builder,
(Builder<Widget> builder) =>
EndlessStateProperty.resolveWith<Widget>((context, states) {
if (states.contains(EndlessState.empty) &&
!states.contains(EndlessState.loading)) {
return builder(context);
}
return null;
}),
);
}
The goal of these defaults like for the empty state is to provide typical behavior for an infinite scroll view. If that’s not the default you would like for your empty state, no problem! You can always provide your own emptyBuilderState
to override it.
So far we’ve seen how to use Endless
scroll views with paginated APIs, but we also highlighted that the library should be extensible to other data sources like streamed data. To use the library with streams, create an EndlessStreamListView
as shown below:
import 'package:flutter/material.dart';
import 'package:endless/endless.dart';
final streamController = StreamController<List<String>>();
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Endless stream list view')),
body: EndlessStreamListView<String>(
// A function called when you scroll past the configurable `extentAfterFactor` to tell the stream to add more items.
loadMore: () => {...},
// Items emitted on the stream are added to the scroll view. The scroll view knows to not try and fetch any more items
// once the stream has completed.
stream: streamController.stream,
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item);
},
),
),
);
}
}
The streamed version of the list view shares most of its functionality with the paginated widget we’ve been using previously, except it now additionally takes a stream
option that the list view subscribes to in order to add new items. When the stream is closed, the list view knows that the end of the list has been reached.
Since our own applications heavily rely on Firestore streams, there is an additional Firestore stream widget available as a separate package that you can checkout if you are working with data from Cloud Firestore.
import 'package:flutter/material.dart';
import 'package:endless_firestore/endless_firestore.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Endless Firestore stream list view')),
body: EndlessFirestoreStreamListView<String>(
// A Firestore stream takes a query to use for fetching items.
query: FirebaseFirestore.instance.collection('users').where('name', isEqualTo: 'Tester'),
// The batch delegate determines how many new items to fetch per batch and optionally the maximum number of batches to fetch.
batchDelegate: EndlessFirestoreStreamBatchDelegate(
batchSize: 5,
maxBatches: 10,
),
itemBuilder: (
context, {
required item,
required index,
required totalItems,
}) {
return Text(item);
},
),
),
);
}
}
In the example above, the EndlessFirestoreStreamListView
displays documents loaded from the specified query into a scrollable list. The scroll view subscribes to the documents returned from the query with the Query.snapshots
API using the Query.limit
approach described in this video from the Firebase team.
Note that this approach incurs a re-read of all current documents when loading successive batches so be aware of the read pricing concerns there. This trade-off was made because of the advantages that come from limit-based batching as best described in the link above.
Firestore streams are just one example of how the library can be extended to support additional custom data sources. If you have your own data sources that you would be interested in seeing support for in the library, feel free to leave a feature request on the project GitHub.
That’s all for now on building infinite scroll views with Endless. Happy coding!