title: Pagination / Infinite scrolling in Flutter with caching and realtime invalidation
body: <p>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:</p>
  
  <ol>
  <li><strong>Smart pagination</strong>: 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.</li>
  <li><strong>Async loading</strong>: the library should basically accept a function which returns a future of a list representing a page.</li>
  <li><strong>Real-time invalidation</strong>: 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.</li>
  </ol>
  
  <p>Basically I wanted something that acted like PagedListAdapter + DataSource.Factory + LiveData in the standard Android library.
  I came up with the widget <strong>PagedListView</strong>:</p>
  
  <pre class="lang-dart prettyprint-override"><code>import 'dart:math';
  
  import 'package:fimber/fimber.dart';
  import 'package:flutter/material.dart';
  
  typedef Future&lt;List&lt;T&gt;&gt; PageFuture&lt;T&gt;(int pageIndex);
  
  typedef Widget ItemBuilder&lt;T&gt;(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&lt;T&gt; extends StatefulWidget {
    final int pageSize;
    final PageFuture&lt;T&gt; pageFuture;
    final Stream&lt;int&gt; countStream;
  
    final ItemBuilder&lt;T&gt; 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&lt;T&gt; createState() =&gt; _PagedListView&lt;T&gt;();
  }
  
  class _PagedListView&lt;T&gt; extends State&lt;PagedListView&lt;T&gt;&gt; {
    /// 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 =&gt; (edgeCachePageCount * 2) + 1;
  
    int currentPage = 0;
  
    List&lt;T&gt; items;
    Object error;
  
    int totalCount = -1;
  
    /// Contains the page indexes which the fetching is started but not completed.
    final progressPages = Set&lt;int&gt;();
  
    /// Contains the page indexes already retrieved.
    final cachedPages = Set&lt;int&gt;();
  
    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 &gt; 0) {
          _fetchPages(PageRequest.SAME);
        }
  
        setState(() {});
      });
    }
  
    void _fetchPages(PageRequest pageRequest) {
      Set&lt;int&gt; refreshIndexes = _getRefreshIndexes();
      //Fimber.i("Refresh indexes are $refreshIndexes");
      refreshIndexes.forEach((pageIndex) =&gt; _fetchPage(pageIndex, pageRequest));
    }
  
    Set&lt;int&gt; _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) =&gt; PageResult&lt;T&gt;(index, request, list)).listen(_onData, onError: _onError);
      }
    }
  
    void _onData(PageResult&lt;T&gt; 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 &gt;= 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&lt;int&gt; 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 &gt; currentPage ? PageRequest.NEXT : (newPage &lt; 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 &lt; limitStartIndex || index &gt; 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&lt;T&gt; {
    /// 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&lt;T&gt; items;
  
    PageResult(this.index, this.request, this.items);
  }
  
  Set&lt;int&gt; getRefreshIndexes(int maxCachedPageCount, int edgeCachePageCount, int currentPage, int pageSize, int totalCount) {
    List&lt;int&gt; temp = List.generate(min(maxCachedPageCount, (totalCount ~/ pageSize) + 1), (index) =&gt; index + (currentPage - edgeCachePageCount));
    int minIndex = temp.reduce(min);
    if (minIndex &lt; 0) {
      return temp.map((index) =&gt; index + minIndex.abs()).toSet();
    }
    int maxIndex = temp.reduce(max);
    int maxPage = totalCount ~/ pageSize;
    if (maxIndex &gt; maxPage) {
      return temp.map((index) =&gt; index - (maxIndex - maxPage)).toSet();
    }
    return temp.toSet();
  }
  </code></pre>
  
  <p>Since I need to know the total amount of items and handle invalidation I figured to accept a <code>Stream&lt;int&gt;</code> which returns the real list size every time data is modified.</p>
  
  <p>This is an example of how it's used:</p>
  
  <pre class="lang-dart prettyprint-override"><code>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) =&gt;
        Future.delayed(Duration(milliseconds: (random.nextDouble() * 5000).toInt()), () =&gt; 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) =&gt; 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) =&gt; Center(child: CircularProgressIndicator());
  
    Widget _placeholderBuilder(BuildContext context) =&gt; Container(
      height: 60,
      margin: EdgeInsets.all(8),
      child: Center(
      child: CircularProgressIndicator(),
      ));
  
    Widget _emptyResultBuilder(BuildContext context) =&gt; Container(
      margin: EdgeInsets.all(8),
      child: Center(
      child: Text("Empty"),
      ));
  
    Widget _errorBuilder(BuildContext context) =&gt; Container(
      color: Colors.red,
      margin: EdgeInsets.all(8),
      child: Center(
      child: Text("Error"),
      ));
  }
  </code></pre>
  
  <p>I'm using SQLite with Moor to retrieve data (<a href="https://moor.simonbinder.eu/docs/" rel="noreferrer">https://moor.simonbinder.eu/docs/</a>).</p>
  
  <ul>
  <li><code>database.getCategories(10, 10 * pageIndex))</code> is a method returning the <code>Future&lt;List&lt;Category&gt;&gt;</code> representing a page</li>
  <li><code>database.countCategories().watchSingle()</code> is the Stream emitting the list size at every add/update/delete</li>
  </ul>
  
  <p>What do you think?
  Am I missing some bugs? Would you done things differently? Maybe in a more simple / elegant / performant way?</p>
  
  <p>Thanks</p>
  
  <p><strong>UPDATE #1</strong></p>
  
  <p>I made a new version based on pskink <a href="https://stackoverflow.com/questions/60074466/pagination-infinite-scrolling-in-flutter-with-caching-and-realtime-invalidatio#comment106248128_60074466">suggestion</a> using LruMap.</p>
  
  <pre class="lang-dart prettyprint-override"><code>import 'package:fimber/fimber.dart';
  import 'package:flutter/material.dart';
  import 'package:quiver/cache.dart';
  import 'package:quiver/collection.dart';
  
  typedef Future&lt;List&lt;T&gt;&gt; PageFuture&lt;T&gt;(int pageIndex);
  
  typedef Widget ItemBuilder&lt;T&gt;(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&lt;T&gt; extends StatefulWidget {
    final int pageSize;
    final PageFuture&lt;T&gt; pageFuture;
    final Stream&lt;int&gt; countStream;
  
    final ItemBuilder&lt;T&gt; 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&lt;T&gt; createState() =&gt; _LazyListView&lt;T&gt;();
  }
  
  class _LazyListView&lt;T&gt; extends State&lt;LazyListView&lt;T&gt;&gt; {
    Map&lt;int, PageResult&lt;T&gt;&gt; map;
    MapCache&lt;int, PageResult&lt;T&gt;&gt; cache;
  
    Object error;
  
    int totalCount = -1;
  
    int currentPage = 0;
  
    @override
    void initState() {
      super.initState();
      map = LruMap&lt;int, PageResult&lt;T&gt;&gt;(maximumSize: 500 ~/ widget.pageSize);
      cache = MapCache&lt;int, PageResult&lt;T&gt;&gt;(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&lt;PageResult&lt;T&gt;&gt; _loadPage(int index) {
      Fimber.i("Start fetch page $index");
      return widget.pageFuture(index).then((list) =&gt; PageResult(index, list));
    }
  
    reload(PageResult&lt;T&gt; value) {
      // Avoid calling setState if it's not needed.
      if ((value.index - currentPage).abs() &gt; 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&lt;T&gt; {
    /// Page index of this data.
    final int index;
  
    final List&lt;T&gt; items;
  
    PageResult(this.index, this.items);
  }
  </code></pre>
  
  <p><strong>UPDATE #2</strong> based on pskink new <a href="https://stackoverflow.com/questions/60074466/pagination-infinite-scrolling-in-flutter-with-caching-and-realtime-invalidatio#comment106262878_60074466">comment</a></p>
  
  <pre class="lang-dart prettyprint-override"><code>import 'package:fimber/fimber.dart';
  import 'package:flutter/material.dart';
  import 'package:quiver/cache.dart';
  import 'package:quiver/collection.dart';
  
  typedef Future&lt;List&lt;T&gt;&gt; PageFuture&lt;T&gt;(int pageIndex);
  
  typedef Widget ItemBuilder&lt;T&gt;(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&lt;T&gt; extends StatefulWidget {
    final int pageSize;
    final PageFuture&lt;T&gt; pageFuture;
    final Stream&lt;int&gt; countStream;
  
    final ItemBuilder&lt;T&gt; 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&lt;T&gt; createState() =&gt; _LazyListView&lt;T&gt;();
  }
  
  class _LazyListView&lt;T&gt; extends State&lt;LazyListView&lt;T&gt;&gt; {
    Map&lt;int, PageResult&lt;T&gt;&gt; map;
    MapCache&lt;int, PageResult&lt;T&gt;&gt; cache;
  
    Object error;
  
    int totalCount = -1;
  
    @override
    void initState() {
      super.initState();
      map = LruMap&lt;int, PageResult&lt;T&gt;&gt;(maximumSize: 50 ~/ widget.pageSize);
      cache = MapCache&lt;int, PageResult&lt;T&gt;&gt;(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&lt;PageResult&lt;T&gt;&gt; _loadPage(int index) {
      Fimber.i("Start fetch page $index");
      return widget.pageFuture(index).then((list) =&gt; PageResult(index, list));
    }
  
    _reload(PageResult&lt;T&gt; 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&lt;T&gt; {
    /// Page index of this data.
    final int index;
  
    final List&lt;T&gt; items;
    bool refreshed = false;
  
    PageResult(this.index, this.items);
  }
  </code></pre>
  
  <p>What dou you think?</p>