Pagination / Infinite scrolling in Flutter with caching and realtime invalidation
Asked Answered
I

1

9

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:

  1. 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.
  2. Async loading: the library should basically accept a function which returns a future of a list representing a page.
  3. 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 the Future<List<Category>> representing a page
  • database.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?

Institute answered 5/2, 2020 at 11:8 Comment(15)
see gist.github.com/pskink/c9bbe21c68efb14eb1c01ba0a4d93414Funch
thanks @pskink, using a LruMap should be an improvement but you're not handling reactivenessInstitute
reactiveness? what you mean by that? check loadIfAbsent - this is a method for lazy data loading which returns a Future - in this particular case each item = one Future but of course in real world it should be paged so for example 10 items = one FutureFunch
I mean that in your example cache invalidation is not considered. There isn't a callback on any sort that says "data must be refreshed"Institute
map[some_index] = null; setState(() {});Funch
i meant cache.invalidate(key), map[key] = null is not enoughFunch
I posted a new version using LruMap and I used map.clear() when the count stream emits a new value since every item could be changed.Institute
i would change reload method: if (value.setStateCalled) return; else {setState(() {}); value.setStateCalled = true;}Funch
Maybe I don't understand but in this way you're not avoiding to call setState when the page that completes is not visible.Institute
recently i found one nice method: Scrollable.recommendDeferredLoadingForContext so try to change your code with: if (loading) { if (!Scrollable.recommendDeferredLoadingForContext(context)) { cache.get(currentPage, ifAbsent: _loadPage).then(reload); } return widget.placeholderBuilder(context); } - there is one if added to skip loding then scrollable scrolls fast - it kind of works (though its a bit strange i confess...) but i did not time to test it muchFunch
but because default implementation of Scrollable.recommendDeferredLoadingForContext is a bit too simple it is better to create a custom ScrollPhysics and override its recommendDeferredLoading methodFunch
I'm gonna look into ScrollPhysics as soon as I get the change! Thanks! Do you have any more specific suggestions?Institute
"Do you have any more specific suggestions?", summarized here: paste.ubuntu.com/p/bZpXwbh9ss you can control when then pages load by changing _LazyListViewPhysics(velocityThreshold: 100)Funch
seems that there were dozens of unwanted scheduleFrameCallback calls, with _frameCallbackInProgress flag there is only one now, see paste.ubuntu.com/p/BDpVkDkVJw and check the logs while flinging the listFunch
I've tried and seems to work. Is there a way I can talk to you in private?Institute
I
7

This is the last version thanks to some very useful suggestions

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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 ErrorBuilder(BuildContext context, dynamic error);

class LazyListView<T> extends StatefulWidget {
  final int pageSize;
  final PageFuture<T> pageFuture;
  final Stream<int> countStream;

  final ItemBuilder<T> itemBuilder;
  final IndexedWidgetBuilder placeholderBuilder;
  final WidgetBuilder waitBuilder;
  final WidgetBuilder emptyResultBuilder;
  final ErrorBuilder errorBuilder;
  final double velocityThreshold;

  LazyListView({
    @required this.pageSize,
    @required this.pageFuture,
    @required this.countStream,
    @required this.itemBuilder,
    @required this.placeholderBuilder,
    this.waitBuilder,
    this.emptyResultBuilder,
    this.errorBuilder,
    this.velocityThreshold = 128,
  })  : assert(pageSize > 0),
        assert(pageFuture != null),
        assert(countStream != null),
        assert(itemBuilder != null),
        assert(placeholderBuilder != null),
        assert(velocityThreshold >= 0);

  @override
  _LazyListViewState<T> createState() => _LazyListViewState<T>();
}

class _LazyListViewState<T> extends State<LazyListView<T>> {
  Map<int, PageResult<T>> map;
  MapCache<int, PageResult<T>> cache;
  dynamic error;
  int totalCount = -1;
  bool _frameCallbackInProgress = false;

  @override
  void initState() {
    super.initState();
    _initCache();

    widget.countStream.listen((int count) {
      totalCount = count;
      _initCache();
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    //debugPrintBeginFrameBanner = true;
    //debugPrintEndFrameBanner = true;
    //print('build');
    if (error != null && widget.errorBuilder != null) return widget.errorBuilder(context, error);
    if (totalCount == -1 && widget.waitBuilder != null) return widget.waitBuilder(context);
    if (totalCount == 0 && widget.emptyResultBuilder != null) return widget.emptyResultBuilder(context);

    return ListView.builder(
      physics: _LazyListViewPhysics(velocityThreshold: widget.velocityThreshold),
      itemCount: max(totalCount, 0),
      itemBuilder: (context, index) {
        // print('builder $index');
        var page = index ~/ widget.pageSize;
        final pageResult = map[page];
        final value = pageResult?.items?.elementAt(index % widget.pageSize);
        if (value != null) {
          return widget.itemBuilder(context, index, value);
        }

        // print('$index ${Scrollable.of(context).position.activity.velocity}');
        if (!Scrollable.recommendDeferredLoadingForContext(context)) {
          cache.get(page, ifAbsent: _loadPage).then(_reload).catchError(_error);
        } else if (!_frameCallbackInProgress) {
          _frameCallbackInProgress = true;
          SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context));
        }
        return widget.placeholderBuilder(context, index);
      },
    );
  }

  Future<PageResult<T>> _loadPage(int index) async {
    print('load $index');
    var list = await widget.pageFuture(index);
    return PageResult(index, list);
  }

  void _initCache() {
    map = LruMap<int, PageResult<T>>(maximumSize: 50 ~/ widget.pageSize);
    cache = MapCache<int, PageResult<T>>(map: map);
  }

  void _error(dynamic e, StackTrace stackTrace) {
    if (widget.errorBuilder == null) {
      throw e;
    }
    setState(() => error = e);
  }

  void _reload(PageResult<T> value) => _doReload(value.index);

  void _deferredReload(BuildContext context) {
    print('_deferredReload');
    if (!Scrollable.recommendDeferredLoadingForContext(context)) {
      _frameCallbackInProgress = false;
      _doReload(-1);
    } else {
      SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context), rescheduling: true);
    }
  }

  void _doReload(int index) {
    // print('reload $index');
    setState(() {});
  }
}

class PageResult<T> {
  /// Page index of this data.
  final int index;
  final List<T> items;

  PageResult(this.index, this.items);
}

class _LazyListViewPhysics extends AlwaysScrollableScrollPhysics {
  final double velocityThreshold;

  _LazyListViewPhysics({
    @required this.velocityThreshold,
    ScrollPhysics parent,
  }) : super(parent: parent);

  @override
  recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    // print('velocityThreshold: $velocityThreshold');
    return velocity.abs() > velocityThreshold;
  }

  @override
  _LazyListViewPhysics applyTo(ScrollPhysics ancestor) {
    // print('applyTo($ancestor)');
    return _LazyListViewPhysics(velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
  }
}

UPDATE #1

This is a new version which ensures futures don't call setState if the widget is unmounted.

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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 ErrorBuilder(BuildContext context, dynamic error);

class LazyListView<T> extends StatefulWidget {
  final int pageSize;
  final PageFuture<T> pageFuture;
  final Stream<int> countStream;

  final ItemBuilder<T> itemBuilder;
  final IndexedWidgetBuilder placeholderBuilder;
  final WidgetBuilder waitBuilder;
  final WidgetBuilder emptyResultBuilder;
  final ErrorBuilder errorBuilder;
  final double velocityThreshold;

  LazyListView({
    @required this.pageSize,
    @required this.pageFuture,
    @required this.countStream,
    @required this.itemBuilder,
    @required this.placeholderBuilder,
    this.waitBuilder,
    this.emptyResultBuilder,
    this.errorBuilder,
    this.velocityThreshold = 128,
  })  : assert(pageSize > 0),
        assert(pageFuture != null),
        assert(countStream != null),
        assert(itemBuilder != null),
        assert(placeholderBuilder != null),
        assert(velocityThreshold >= 0);

  @override
  _LazyListViewState<T> createState() => _LazyListViewState<T>();
}

class _LazyListViewState<T> extends State<LazyListView<T>> {
  Map<int, PageResult<T>> map;
  MapCache<int, PageResult<T>> cache;
  dynamic error;
  int totalCount = -1;
  bool _frameCallbackInProgress = false;

  StreamSubscription<int> countStreamSubscription;

  @override
  void initState() {
    super.initState();
    _initCache();

    countStreamSubscription = widget.countStream.listen((int count) {
      totalCount = count;
      print('totalCount = $totalCount');
      _initCache();
      setState(() {});
    });
  }

  @override
  void dispose() {
    countStreamSubscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    //debugPrintBeginFrameBanner = true;
    //debugPrintEndFrameBanner = true;
    //print('build');
    if (error != null && widget.errorBuilder != null) {
      return widget.errorBuilder(context, error);
    }
    if (totalCount == -1 && widget.waitBuilder != null) {
      return widget.waitBuilder(context);
    }
    if (totalCount == 0 && widget.emptyResultBuilder != null) {
      return widget.emptyResultBuilder(context);
    }

    return ListView.builder(
      physics: _LazyListViewPhysics(velocityThreshold: widget.velocityThreshold),
      itemCount: max(totalCount, 0),
      itemBuilder: (context, index) {
        // print('builder $index');
        final page = index ~/ widget.pageSize;
        final pageResult = map[page];
        final value = pageResult?.items?.elementAt(index % widget.pageSize);
        if (value != null) {
          return widget.itemBuilder(context, index, value);
        }

        // print('$index ${Scrollable.of(context).position.activity.velocity}');
        if (!Scrollable.recommendDeferredLoadingForContext(context)) {
          cache.get(page, ifAbsent: _loadPage).then(_reload).catchError(_error);
        } else if (!_frameCallbackInProgress) {
          _frameCallbackInProgress = true;
          SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context));
        }
        return widget.placeholderBuilder(context, index);
      },
    );
  }

  Future<PageResult<T>> _loadPage(int index) async {
    print('load $index');
    var list = await widget.pageFuture(index);
    return PageResult(index, list);
  }

  void _initCache() {
    map = LruMap<int, PageResult<T>>(maximumSize: 512 ~/ widget.pageSize);
    cache = MapCache<int, PageResult<T>>(map: map);
  }

  void _error(dynamic e, StackTrace stackTrace) {
    if (widget.errorBuilder == null) {
      throw e;
    }
    if (this.mounted) {
      setState(() => error = e);
    }
  }

  void _reload(PageResult<T> value) => _doReload(value.index);

  void _deferredReload(BuildContext context) {
    print('_deferredReload');
    if (!Scrollable.recommendDeferredLoadingForContext(context)) {
      _frameCallbackInProgress = false;
      _doReload(-1);
    } else {
      SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context), rescheduling: true);
    }
  }

  void _doReload(int index) {
    print('reload $index');
    if (this.mounted) {
      setState(() {});
    }
  }
}

class PageResult<T> {
  /// Page index of this data.
  final int index;
  final List<T> items;

  PageResult(this.index, this.items);
}

class _LazyListViewPhysics extends AlwaysScrollableScrollPhysics {
  final double velocityThreshold;

  _LazyListViewPhysics({
    @required this.velocityThreshold,
    ScrollPhysics parent,
  }) : super(parent: parent);

  @override
  recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    // print('velocityThreshold: $velocityThreshold');
    return velocity.abs() > velocityThreshold;
  }

  @override
  _LazyListViewPhysics applyTo(ScrollPhysics ancestor) {
    // print('applyTo($ancestor)');
    return _LazyListViewPhysics(velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
  }
}

Anyone with a better idea?

Institute answered 20/2, 2020 at 8:32 Comment(4)
recommendDeferredLoading doesn't override anything and recommendDeferredLoadingForContext doesn't exist anymore. Do you have an updated version of your solution? ThanksWingo
Hi @unveloper, There is other important feature in paging system in android, "Boundaries Callbacks" after you rich DB boundaries. this functionalities will help developers to fetch more data from server if needed.Load
I love it. 1) Make it into a regular plugin on pub.dev, it will be much more discoverable than here. 2) Add a simple check to see if placeholderBuilder gets a height of zero (like returning a simple Container() there, something probably everybody would commit first). With zero height, it will start filling up everything up front.Shoelace
I took the liberty of turning it into a package, huge_listview for Flutter, with due credit to you as the original idea and originator behind it.Shoelace

© 2022 - 2024 — McMap. All rights reserved.