It's been a long time since I started to search for a Flutter ListView library that will allow me to use pagination in a smart way. Sadly I haven't found anything that meets my criteria:
- Smart pagination: the library should't simply increase a list page-by-page but must have a fixed size cache which load and keep in memory only the needed pages in the moment.
- Async loading: the library should basically accept a function which returns a future of a list representing a page.
- Real-time invalidation: Dart has streams, so somehow the library should use their power to handle invalidation and reload everything needed when data changes in a reactive way.
Basically I wanted something that acted like PagedListAdapter + DataSource.Factory + LiveData in the standard Android library. I came up with the widget PagedListView:
import 'dart:math';
import 'package:fimber/fimber.dart';
import 'package:flutter/material.dart';
typedef Future<List<T>> PageFuture<T>(int pageIndex);
typedef Widget ItemBuilder<T>(BuildContext context, int index, T entry);
typedef Widget WaitBuilder(BuildContext context);
typedef Widget PlaceholderBuilder(BuildContext context);
typedef Widget EmptyResultBuilder(BuildContext context);
typedef Widget ErrorBuilder(BuildContext context);
class PagedListView<T> extends StatefulWidget {
final int pageSize;
final PageFuture<T> pageFuture;
final Stream<int> countStream;
final ItemBuilder<T> itemBuilder;
final WaitBuilder waitBuilder;
final PlaceholderBuilder placeholderBuilder;
final EmptyResultBuilder emptyResultBuilder;
final ErrorBuilder errorBuilder;
PagedListView(
{@required this.pageSize,
@required this.pageFuture,
@required this.countStream,
@required this.itemBuilder,
@required this.waitBuilder,
@required this.placeholderBuilder,
@required this.emptyResultBuilder,
@required this.errorBuilder});
@override
_PagedListView<T> createState() => _PagedListView<T>();
}
class _PagedListView<T> extends State<PagedListView<T>> {
/// Represent the number of cached pages before and after the current page.
/// If edgeCachePageCount = 1 the total number of cached pages are 3 (one before + current + one after).
/// TODO calculate from pageSize
final int edgeCachePageCount = 2;
int get maxCachedPageCount => (edgeCachePageCount * 2) + 1;
int currentPage = 0;
List<T> items;
Object error;
int totalCount = -1;
/// Contains the page indexes which the fetching is started but not completed.
final progressPages = Set<int>();
/// Contains the page indexes already retrieved.
final cachedPages = Set<int>();
int limitStartIndex = -1;
int limitEndIndex = -1;
@override
void initState() {
super.initState();
items = List.filled(widget.pageSize * maxCachedPageCount, null);
widget.countStream.listen((int count) {
Fimber.i("Total count changed: $count");
totalCount = count;
// Invalidate.
cachedPages.clear();
if (count > 0) {
_fetchPages(PageRequest.SAME);
}
setState(() {});
});
}
void _fetchPages(PageRequest pageRequest) {
Set<int> refreshIndexes = _getRefreshIndexes();
//Fimber.i("Refresh indexes are $refreshIndexes");
refreshIndexes.forEach((pageIndex) => _fetchPage(pageIndex, pageRequest));
}
Set<int> _getRefreshIndexes() {
return getRefreshIndexes(maxCachedPageCount, edgeCachePageCount, currentPage, widget.pageSize, totalCount);
}
_fetchPage(int index, PageRequest request) {
if (cachedPages.contains(index)) {
// We already have this page.
return;
}
if (!progressPages.contains(index)) {
//Fimber.i("Fetch page $index start");
progressPages.add(index);
widget.pageFuture(index).asStream().map((list) => PageResult<T>(index, request, list)).listen(_onData, onError: _onError);
}
}
void _onData(PageResult<T> data) {
if (data.items != null) {
if (!_getRefreshIndexes().contains(data.index)) {
progressPages.remove(data.index);
//Fimber.i("Skipping invalid page ${data.index}, currentPage = $currentPage, refreshIndexes = ${_getRefreshIndexes()}");
return;
}
//Fimber.i("Fetch page ${data.index} end");
if (cachedPages.length == maxCachedPageCount) {
// The cached page count is exceeded, remove the smallest / greatest page.
if (data.request == PageRequest.NEXT) {
int smallestPage = cachedPages.reduce(min);
cachedPages.remove(smallestPage);
//Fimber.i("Smallest page $smallestPage removed");
} else if (data.request == PageRequest.PREVIOUS) {
int greatestPage = cachedPages.reduce(max);
cachedPages.remove(greatestPage);
//Fimber.i("Greatest page $greatestPage removed");
} else {
int smallestPage = cachedPages.reduce(min);
int greatestPage = cachedPages.reduce(max);
int smallestPageDistance = currentPage - smallestPage;
int greatestPageDistance = greatestPage - currentPage;
if (smallestPageDistance >= greatestPageDistance) {
//Fimber.i("Smallest page $smallestPage removed, smallestPageDistance = $smallestPageDistance, greatestPageDistance = $greatestPageDistance");
cachedPages.remove(smallestPage);
} else {
//Fimber.i("Greatest page $greatestPage removed, smallestPageDistance = $smallestPageDistance, greatestPageDistance = $greatestPageDistance");
cachedPages.remove(greatestPage);
}
}
}
Set<int> tempCachedPages = cachedPages.toSet()..add(data.index);
// Put the result in the correct position.
int startIndex = widget.pageSize * (data.index % maxCachedPageCount);
items.setAll(startIndex, data.items);
//Fimber.i("Fetch page ${data.index} end, startIndex = $startIndex");
limitStartIndex = cachedPages.isEmpty ? 0 : tempCachedPages.reduce(min) * widget.pageSize;
//Fimber.i("limitStartIndex set to $limitStartIndex");
limitEndIndex = cachedPages.isEmpty ? -1 : (widget.pageSize * tempCachedPages.reduce(max)) + data.items.length - 1;
//Fimber.i("limitEndIndex set to $limitEndIndex");
cachedPages.add(data.index);
progressPages.remove(data.index);
//Fimber.i("Fetch page ${data.index} end, startIndex = $startIndex, cached pages ${cachedPages.toList()..sort()}, currentPage = $currentPage");
setState(() {});
}
}
void _onError(error) {
this.error = error;
setState(() {});
}
_fetchNewPage(int index) {
int newPage = index ~/ widget.pageSize;
PageRequest pageRequest = newPage > currentPage ? PageRequest.NEXT : (newPage < currentPage ? PageRequest.PREVIOUS : PageRequest.SAME);
/*pageRequest == PageRequest.NEXT
? Fimber.i("Fetch next page $newPage")
: (pageRequest == PageRequest.PREVIOUS ? Fimber.i("Fetch previous page $newPage") : null);*/
currentPage = newPage;
_fetchPages(pageRequest);
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
if (error != null) {
return widget.errorBuilder(context);
}
if (totalCount == -1) {
return widget.waitBuilder(context);
}
if (totalCount == 0) {
return widget.emptyResultBuilder(context);
}
return ListView.builder(
key: Key("listView"),
itemCount: totalCount,
itemBuilder: (context, index) {
if (index < limitStartIndex || index > limitEndIndex) {
_fetchNewPage(index);
}
return _getListItem(context, index);
},
);
}
Widget _getListItem(BuildContext context, int realIndex) {
int pageIndex = realIndex ~/ widget.pageSize;
if (!cachedPages.contains(pageIndex)) {
return widget.placeholderBuilder(context);
}
int cachePageIndex = pageIndex % maxCachedPageCount;
int cacheIndex = (cachePageIndex * widget.pageSize) + (realIndex % widget.pageSize);
return widget.itemBuilder(context, realIndex, items[cacheIndex]);
}
}
enum PageRequest { NEXT, PREVIOUS, SAME }
class PageResult<T> {
/// Page index of this data.
final int index;
/// Represent the direction from the current page when the request was made.
final PageRequest request;
final List<T> items;
PageResult(this.index, this.request, this.items);
}
Set<int> getRefreshIndexes(int maxCachedPageCount, int edgeCachePageCount, int currentPage, int pageSize, int totalCount) {
List<int> temp = List.generate(min(maxCachedPageCount, (totalCount ~/ pageSize) + 1), (index) => index + (currentPage - edgeCachePageCount));
int minIndex = temp.reduce(min);
if (minIndex < 0) {
return temp.map((index) => index + minIndex.abs()).toSet();
}
int maxIndex = temp.reduce(max);
int maxPage = totalCount ~/ pageSize;
if (maxIndex > maxPage) {
return temp.map((index) => index - (maxIndex - maxPage)).toSet();
}
return temp.toSet();
}
Since I need to know the total amount of items and handle invalidation I figured to accept a Stream<int>
which returns the real list size every time data is modified.
This is an example of how it's used:
class MyHomePage extends StatelessWidget {
final MyDatabase database = MyDatabase();
MyHomePage({Key key}) : super(key: key);
Random random = Random.secure();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Test"),
),
body: PagedListView(
pageSize: 10,
pageFuture: (pageIndex) =>
Future.delayed(Duration(milliseconds: (random.nextDouble() * 5000).toInt()), () => database.getCategories(10, 10 * pageIndex)),
countStream: database.countCategories().watchSingle(),
itemBuilder: _itemBuilder,
waitBuilder: _waitBuilder,
placeholderBuilder: _placeholderBuilder,
emptyResultBuilder: _emptyResultBuilder,
errorBuilder: _errorBuilder,
),
);
}
Widget _itemBuilder(BuildContext context, int index, Category item) => Container(
height: 60,
child: Center(
child: ListTile(
key: Key(item.id.toString()),
title: Text(item.description),
subtitle: Text("id = ${item.id}, index = $index")
),
),
);
Widget _waitBuilder(BuildContext context) => Center(child: CircularProgressIndicator());
Widget _placeholderBuilder(BuildContext context) => Container(
height: 60,
margin: EdgeInsets.all(8),
child: Center(
child: CircularProgressIndicator(),
));
Widget _emptyResultBuilder(BuildContext context) => Container(
margin: EdgeInsets.all(8),
child: Center(
child: Text("Empty"),
));
Widget _errorBuilder(BuildContext context) => Container(
color: Colors.red,
margin: EdgeInsets.all(8),
child: Center(
child: Text("Error"),
));
}
I'm using SQLite with Moor to retrieve data (https://moor.simonbinder.eu/docs/).
database.getCategories(10, 10 * pageIndex))
is a method returning theFuture<List<Category>>
representing a pagedatabase.countCategories().watchSingle()
is the Stream emitting the list size at every add/update/delete
What do you think? Am I missing some bugs? Would you done things differently? Maybe in a more simple / elegant / performant way?
Thanks
UPDATE #1
I made a new version based on pskink suggestion using LruMap.
import 'package:fimber/fimber.dart';
import 'package:flutter/material.dart';
import 'package:quiver/cache.dart';
import 'package:quiver/collection.dart';
typedef Future<List<T>> PageFuture<T>(int pageIndex);
typedef Widget ItemBuilder<T>(BuildContext context, int index, T entry);
typedef Widget WaitBuilder(BuildContext context);
typedef Widget PlaceholderBuilder(BuildContext context);
typedef Widget EmptyResultBuilder(BuildContext context);
typedef Widget ErrorBuilder(BuildContext context);
class LazyListView<T> extends StatefulWidget {
final int pageSize;
final PageFuture<T> pageFuture;
final Stream<int> countStream;
final ItemBuilder<T> itemBuilder;
final WaitBuilder waitBuilder;
final PlaceholderBuilder placeholderBuilder;
final EmptyResultBuilder emptyResultBuilder;
final ErrorBuilder errorBuilder;
LazyListView(
{@required this.pageSize,
@required this.pageFuture,
@required this.countStream,
@required this.itemBuilder,
@required this.waitBuilder,
@required this.placeholderBuilder,
@required this.emptyResultBuilder,
@required this.errorBuilder});
@override
_LazyListView<T> createState() => _LazyListView<T>();
}
class _LazyListView<T> extends State<LazyListView<T>> {
Map<int, PageResult<T>> map;
MapCache<int, PageResult<T>> cache;
Object error;
int totalCount = -1;
int currentPage = 0;
@override
void initState() {
super.initState();
map = LruMap<int, PageResult<T>>(maximumSize: 500 ~/ widget.pageSize);
cache = MapCache<int, PageResult<T>>(map: map);
widget.countStream.listen((int count) {
Fimber.i("Total count changed: $count");
totalCount = count;
map.clear();
setState(() {});
});
}
@override
Widget build(BuildContext context) {
if (error != null) {
return widget.errorBuilder(context);
}
if (totalCount == -1) {
return widget.waitBuilder(context);
}
if (totalCount == 0) {
return widget.emptyResultBuilder(context);
}
return ListView.builder(
key: Key("listView"),
itemCount: totalCount,
itemBuilder: (context, index) {
currentPage = index ~/ widget.pageSize;
final pageResult = map[currentPage];
final value = pageResult == null ? null : pageResult.items[index % widget.pageSize];
final loading = (value == null);
if (loading) {
cache.get(currentPage, ifAbsent: _loadPage).then(reload);
return widget.placeholderBuilder(context);
}
return widget.itemBuilder(context, index, value);
},
);
}
Future<PageResult<T>> _loadPage(int index) {
Fimber.i("Start fetch page $index");
return widget.pageFuture(index).then((list) => PageResult(index, list));
}
reload(PageResult<T> value) {
// Avoid calling setState if it's not needed.
if ((value.index - currentPage).abs() > 2) {
// ATTENTION: 2 is an arbitrary value, the distance between the current page and the page in the future result should ensure correct refreshing.
// It should be greater if item widgets have a smaller height, can be smaller if item widgets have a greater height.
// TODO: make it configurable?
Fimber.i("Skipping refreshing for result of page ${value.index}, currentPage = $currentPage");
return;
}
setState(() {});
}
}
class PageResult<T> {
/// Page index of this data.
final int index;
final List<T> items;
PageResult(this.index, this.items);
}
UPDATE #2 based on pskink new comment
import 'package:fimber/fimber.dart';
import 'package:flutter/material.dart';
import 'package:quiver/cache.dart';
import 'package:quiver/collection.dart';
typedef Future<List<T>> PageFuture<T>(int pageIndex);
typedef Widget ItemBuilder<T>(BuildContext context, int index, T entry);
typedef Widget WaitBuilder(BuildContext context);
typedef Widget PlaceholderBuilder(BuildContext context);
typedef Widget EmptyResultBuilder(BuildContext context);
typedef Widget ErrorBuilder(BuildContext context);
class LazyListView<T> extends StatefulWidget {
final int pageSize;
final PageFuture<T> pageFuture;
final Stream<int> countStream;
final ItemBuilder<T> itemBuilder;
final WaitBuilder waitBuilder;
final PlaceholderBuilder placeholderBuilder;
final EmptyResultBuilder emptyResultBuilder;
final ErrorBuilder errorBuilder;
LazyListView(
{@required this.pageSize,
@required this.pageFuture,
@required this.countStream,
@required this.itemBuilder,
@required this.waitBuilder,
@required this.placeholderBuilder,
@required this.emptyResultBuilder,
@required this.errorBuilder});
@override
_LazyListView<T> createState() => _LazyListView<T>();
}
class _LazyListView<T> extends State<LazyListView<T>> {
Map<int, PageResult<T>> map;
MapCache<int, PageResult<T>> cache;
Object error;
int totalCount = -1;
@override
void initState() {
super.initState();
map = LruMap<int, PageResult<T>>(maximumSize: 50 ~/ widget.pageSize);
cache = MapCache<int, PageResult<T>>(map: map);
widget.countStream.listen((int count) {
Fimber.i("Total count changed: $count");
totalCount = count;
map.clear();
setState(() {});
});
}
@override
Widget build(BuildContext context) {
if (error != null) {
return widget.errorBuilder(context);
}
if (totalCount == -1) {
return widget.waitBuilder(context);
}
if (totalCount == 0) {
return widget.emptyResultBuilder(context);
}
return ListView.builder(
key: Key("listView"),
itemCount: totalCount,
itemBuilder: (context, index) {
int currentPage = index ~/ widget.pageSize;
final pageResult = map[currentPage];
final value = pageResult == null ? null : pageResult.items[index % widget.pageSize];
final loading = (value == null);
if (loading) {
cache.get(currentPage, ifAbsent: _loadPage).then(_reload);
return widget.placeholderBuilder(context);
}
return widget.itemBuilder(context, index, value);
},
);
}
Future<PageResult<T>> _loadPage(int index) {
Fimber.i("Start fetch page $index");
return widget.pageFuture(index).then((list) => PageResult(index, list));
}
_reload(PageResult<T> value) {
if (value.refreshed) {
// Avoid calling setState if already called.
Fimber.i("Skipping refreshing for result of page ${value.index}");
return;
}
setState(() {
value.refreshed = true;
});
}
}
class PageResult<T> {
/// Page index of this data.
final int index;
final List<T> items;
bool refreshed = false;
PageResult(this.index, this.items);
}
What dou you think?
This is the last version thanks to some very useful suggestions
UPDATE #1
This is a new version which ensures futures don't call
setState
if the widget is unmounted.Anyone with a better idea?