listviewflutterlazy-loadingupdown

Flutter ListView lazy loading in both directions (up, down)


I would like to have a ListView in flutter which provides lazy loading in both directions (up, down).

Example:

Things to consider:

What I've tried:

I hope that here are some pretty smart people who can help me solving this issue ;). I'm already searching and trying around since days on this issue. Thanks!

Update To make things clearer: Here is an example for a ListView with lazy loading for scrolling up and down (Most of the code copied from https://stackoverflow.com/a/49509349/10905712 by Rémi Rousselet):

import 'dart:math';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class MyHome extends StatefulWidget {
  @override
  _MyHomeState createState() => new _MyHomeState();
}

class _MyHomeState extends State<MyHome> {
  ScrollController controller;
  List<String> items = new List.generate(100, (index) => 'Hello $index');

  @override
  void initState() {
    super.initState();
    controller = new ScrollController()..addListener(_scrollListener);
  }

  @override
  void dispose() {
    controller.removeListener(_scrollListener);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Scrollbar(
        child: new ListView.builder(
          controller: controller,
          itemBuilder: (context, index) {
            return new Text(items[index]);
          },
          itemCount: items.length,
        ),
      ),
    );
  }

  double oldScrollPosition = 0.0;
  void _scrollListener() {
    bool scrollingDown = oldScrollPosition < controller.position.pixels;
    print(controller.position.extentAfter);

    if (controller.position.extentAfter < 500 && scrollingDown) {
      setState(() {
        items.addAll(new List.generate(
            42, (int index) => Random().nextInt(10000).toString()));
      });
    } else if (controller.position.extentBefore < 500 && !scrollingDown) {
      setState(() {
        items.insertAll(
            0,
            new List.generate(
                42, (index) => Random().nextInt(10000).toString()));
      });
    }

    oldScrollPosition = controller.position.pixels;
  }
}

If you execute this code and try scrolling up, you'll see a "jumping" in the list. Scrolling down + lazy load works perfectly. Scrolling up + lazy load would work if the ListView would be reversed. Anyhow, with this solution we would have the same issue with scrolling down + lazy load here.


Solution

  • Update

    I just created a new library bidirectional_listview which can be used to solve this issue. BidirectionalListView is a fork from infinite_listview.

    Old Answer

    I just solved it by adapting the library InfiniteListView a bit. I had to extend a setter for minScrollExtent and maxScrollExtent. In addition, I added a separate count for negative indices:

    library infinite_listview;
    
    import 'dart:math' as math;
    
    import 'package:flutter/rendering.dart';
    import 'package:flutter/widgets.dart';
    
    /// Infinite ListView
    ///
    /// ListView that builds its children with to an infinite extent.
    ///
    class BidirectionalListView extends StatelessWidget {
      /// See [ListView.builder]
      BidirectionalListView.builder({
        Key key,
        this.scrollDirection = Axis.vertical,
        BidirectionalScrollController controller,
        this.physics,
        this.padding,
        this.itemExtent,
        @required IndexedWidgetBuilder itemBuilder,
        int itemCount,
        int negativeItemCount,
        bool addAutomaticKeepAlives = true,
        bool addRepaintBoundaries = true,
        this.anchor = 0.0,
        this.cacheExtent,
      })  : positiveChildrenDelegate = SliverChildBuilderDelegate(
              itemBuilder,
              childCount: itemCount,
              addAutomaticKeepAlives: addAutomaticKeepAlives,
              addRepaintBoundaries: addRepaintBoundaries,
            ),
            negativeChildrenDelegate = SliverChildBuilderDelegate(
              (BuildContext context, int index) => itemBuilder(context, -1 - index),
              childCount: negativeItemCount,
              addAutomaticKeepAlives: addAutomaticKeepAlives,
              addRepaintBoundaries: addRepaintBoundaries,
            ),
            controller = controller ?? BidirectionalScrollController(),
            super(key: key);
    
      /// See [ListView.separated]
      BidirectionalListView.separated({
        Key key,
        this.scrollDirection = Axis.vertical,
        BidirectionalScrollController controller,
        this.physics,
        this.padding,
        @required IndexedWidgetBuilder itemBuilder,
        @required IndexedWidgetBuilder separatorBuilder,
        int itemCount,
        int negativeItemCount,
        bool addAutomaticKeepAlives = true,
        bool addRepaintBoundaries = true,
        this.cacheExtent,
        this.anchor = 0.0,
      })  : assert(itemBuilder != null),
            assert(separatorBuilder != null),
            itemExtent = null,
            positiveChildrenDelegate = SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                final itemIndex = index ~/ 2;
                return index.isEven
                    ? itemBuilder(context, itemIndex)
                    : separatorBuilder(context, itemIndex);
              },
              childCount: itemCount != null ? math.max(0, itemCount * 2 - 1) : null,
              addAutomaticKeepAlives: addAutomaticKeepAlives,
              addRepaintBoundaries: addRepaintBoundaries,
            ),
            negativeChildrenDelegate = SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                final itemIndex = (-1 - index) ~/ 2;
                return index.isOdd
                    ? itemBuilder(context, itemIndex)
                    : separatorBuilder(context, itemIndex);
              },
              childCount: negativeItemCount,
              addAutomaticKeepAlives: addAutomaticKeepAlives,
              addRepaintBoundaries: addRepaintBoundaries,
            ),
            controller = controller ?? BidirectionalScrollController(),
            super(key: key);
    
      /// See: [ScrollView.scrollDirection]
      final Axis scrollDirection;
    
      /// See: [ScrollView.controller]
      final BidirectionalScrollController controller;
    
      /// See: [ScrollView.physics]
      final ScrollPhysics physics;
    
      /// See: [BoxScrollView.padding]
      final EdgeInsets padding;
    
      /// See: [ListView.itemExtent]
      final double itemExtent;
    
      /// See: [ScrollView.cacheExtent]
      final double cacheExtent;
    
      /// See: [ScrollView.anchor]
      final double anchor;
    
      /// See: [ListView.childrenDelegate]
      final SliverChildDelegate negativeChildrenDelegate;
    
      /// See: [ListView.childrenDelegate]
      final SliverChildDelegate positiveChildrenDelegate;
    
      @override
      Widget build(BuildContext context) {
        final List<Widget> slivers = _buildSlivers(context, negative: false);
        final List<Widget> negativeSlivers = _buildSlivers(context, negative: true);
        final AxisDirection axisDirection = _getDirection(context);
        final scrollPhysics = AlwaysScrollableScrollPhysics(parent: physics);
        return Scrollable(
          axisDirection: axisDirection,
          controller: controller,
          physics: scrollPhysics,
          viewportBuilder: (BuildContext context, ViewportOffset offset) {
            return Builder(builder: (BuildContext context) {
              /// Build negative [ScrollPosition] for the negative scrolling [Viewport].
              final state = Scrollable.of(context);
              final negativeOffset = BidirectionalScrollPosition(
                physics: scrollPhysics,
                context: state,
                initialPixels: -offset.pixels,
                keepScrollOffset: controller.keepScrollOffset,
                negativeScroll: true,
              );
    
              /// Keep the negative scrolling [Viewport] positioned to the [ScrollPosition].
              offset.addListener(() {
                negativeOffset._forceNegativePixels(offset.pixels);
              });
    
              /// Stack the two [Viewport]s on top of each other so they move in sync.
              return Stack(
                children: <Widget>[
                  Viewport(
                    axisDirection: flipAxisDirection(axisDirection),
                    anchor: 1.0 - anchor,
                    offset: negativeOffset,
                    slivers: negativeSlivers,
                    cacheExtent: cacheExtent,
                  ),
                  Viewport(
                    axisDirection: axisDirection,
                    anchor: anchor,
                    offset: offset,
                    slivers: slivers,
                    cacheExtent: cacheExtent,
                  ),
                ],
              );
            });
          },
        );
      }
    
      AxisDirection _getDirection(BuildContext context) {
        return getAxisDirectionFromAxisReverseAndDirectionality(
            context, scrollDirection, false);
      }
    
      List<Widget> _buildSlivers(BuildContext context, {bool negative = false}) {
        Widget sliver;
        if (itemExtent != null) {
          sliver = SliverFixedExtentList(
            delegate:
                negative ? negativeChildrenDelegate : positiveChildrenDelegate,
            itemExtent: itemExtent,
          );
        } else {
          sliver = SliverList(
              delegate:
                  negative ? negativeChildrenDelegate : positiveChildrenDelegate);
        }
        if (padding != null) {
          sliver = new SliverPadding(
            padding: negative
                ? padding - EdgeInsets.only(bottom: padding.bottom)
                : padding - EdgeInsets.only(top: padding.top),
            sliver: sliver,
          );
        }
        return <Widget>[sliver];
      }
    
      @override
      void debugFillProperties(DiagnosticPropertiesBuilder properties) {
        super.debugFillProperties(properties);
        properties.add(new EnumProperty<Axis>('scrollDirection', scrollDirection));
        properties.add(new DiagnosticsProperty<ScrollController>(
            'controller', controller,
            showName: false, defaultValue: null));
        properties.add(new DiagnosticsProperty<ScrollPhysics>('physics', physics,
            showName: false, defaultValue: null));
        properties.add(new DiagnosticsProperty<EdgeInsetsGeometry>(
            'padding', padding,
            defaultValue: null));
        properties
            .add(new DoubleProperty('itemExtent', itemExtent, defaultValue: null));
        properties.add(
            new DoubleProperty('cacheExtent', cacheExtent, defaultValue: null));
      }
    }
    
    /// Same as a [ScrollController] except it provides [ScrollPosition] objects with infinite bounds.
    class BidirectionalScrollController extends ScrollController {
      /// Creates a new [BidirectionalScrollController]
      BidirectionalScrollController({
        double initialScrollOffset = 0.0,
        bool keepScrollOffset = true,
        String debugLabel,
      }) : super(
              initialScrollOffset: initialScrollOffset,
              keepScrollOffset: keepScrollOffset,
              debugLabel: debugLabel,
            );
    
      @override
      ScrollPosition createScrollPosition(ScrollPhysics physics,
          ScrollContext context, ScrollPosition oldPosition) {
        return new BidirectionalScrollPosition(
          physics: physics,
          context: context,
          initialPixels: initialScrollOffset,
          keepScrollOffset: keepScrollOffset,
          oldPosition: oldPosition,
          debugLabel: debugLabel,
        );
      }
    }
    
    class BidirectionalScrollPosition extends ScrollPositionWithSingleContext {
      BidirectionalScrollPosition({
        @required ScrollPhysics physics,
        @required ScrollContext context,
        double initialPixels = 0.0,
        bool keepScrollOffset = true,
        ScrollPosition oldPosition,
        String debugLabel,
        this.negativeScroll = false,
      })  : assert(negativeScroll != null),
            super(
              physics: physics,
              context: context,
              initialPixels: initialPixels,
              keepScrollOffset: keepScrollOffset,
              oldPosition: oldPosition,
              debugLabel: debugLabel,
            ) {
        if (oldPosition != null &&
            oldPosition.minScrollExtent != null &&
            oldPosition.maxScrollExtent != null) {
          _minScrollExtent = oldPosition.minScrollExtent;
          _maxScrollExtent = oldPosition.maxScrollExtent;
        }
      }
    
      final bool negativeScroll;
    
      void _forceNegativePixels(double value) {
        super.forcePixels(-value);
      }
    
      @override
      double get minScrollExtent => _minScrollExtent;
      double _minScrollExtent = 0.0;
    
      @override
      double get maxScrollExtent => _maxScrollExtent;
      double _maxScrollExtent = 0.0;
    
      void setMinMaxExtent(double minExtent, double maxExtent) {
        _minScrollExtent = minExtent;
        _maxScrollExtent = maxExtent;
      }
    
      @override
      void saveScrollOffset() {
        if (!negativeScroll) {
          super.saveScrollOffset();
        }
      }
    
      @override
      void restoreScrollOffset() {
        if (!negativeScroll) {
          super.restoreScrollOffset();
        }
      }
    }
    

    The following example demonstrates lazy loading with scroll boundaries in both directions, up and down:

    import 'dart:math';
    
    import 'package:flutter/cupertino.dart';
    import 'package:flutter/material.dart';
    import 'package:tiverme/ui/helpwidgets/BidirectionalListView.dart';
    
    class MyHome extends StatefulWidget {
      @override
      _MyHomeState createState() => new _MyHomeState();
    }
    
    class _MyHomeState extends State<MyHome> {
      BidirectionalScrollController controller;
      Map<int, String> items = new Map();
      static const double ITEM_HEIGHT = 30;
    
      @override
      void initState() {
        super.initState();
    
        for (int i = -10; i <= 10; i++) {
          items[i] = "Item " + i.toString();
        }
        controller = new BidirectionalScrollController()
          ..addListener(_scrollListener);
      }
    
      @override
      void dispose() {
        controller.removeListener(_scrollListener);
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        List<int> keys = items.keys.toList();
        keys.sort();
    
        int negativeItemCount = keys.first;
        int itemCount = keys.last;
        print("itemCount = " + itemCount.toString());
        print("negativeItemCount = " + negativeItemCount.abs().toString());
        return new Scaffold(
          body: new Scrollbar(
            child: new BidirectionalListView.builder(
              controller: controller,
              physics: AlwaysScrollableScrollPhysics(),
              itemBuilder: (context, index) {
                return Container(
                    child: Text(items[index]),
                    height: ITEM_HEIGHT,
                    padding: EdgeInsets.all(0),
                    margin: EdgeInsets.all(0));
              },
              itemCount: itemCount,
              negativeItemCount: negativeItemCount.abs(),
            ),
          ),
        );
      }
    
      void _rebuild() => setState(() {});
    
      double oldScrollPosition = 0.0;
      void _scrollListener() {
        bool scrollingDown = oldScrollPosition < controller.position.pixels;
        List<int> keys = items.keys.toList();
        keys.sort();
        int negativeItemCount = keys.first.abs();
        int itemCount = keys.last;
    
        double positiveReloadBorder = (itemCount * ITEM_HEIGHT - 3 * ITEM_HEIGHT);
        double negativeReloadBorder =
            (-(negativeItemCount * ITEM_HEIGHT - 3 * ITEM_HEIGHT));
    
        print("pixels = " + controller.position.pixels.toString());
        print("itemCount = " + itemCount.toString());
        print("negativeItemCount = " + negativeItemCount.toString());
        print("minExtent = " + controller.position.minScrollExtent.toString());
        print("maxExtent = " + controller.position.maxScrollExtent.toString());
        print("positiveReloadBorder = " + positiveReloadBorder.toString());
        print("negativeReloadBorder = " + negativeReloadBorder.toString());
    
        bool rebuildNecessary = false;
        if (scrollingDown && controller.position.pixels > positiveReloadBorder) {
          for (int i = itemCount + 1; i <= itemCount + 20; i++) {
            items[i] = "Item " + i.toString();
          }
          rebuildNecessary = true;
        } else if (!scrollingDown &&
            controller.position.pixels < negativeReloadBorder) {
          for (int i = -negativeItemCount - 20; i < -negativeItemCount; i++) {
            items[i] = "Item " + i.toString();
          }
          rebuildNecessary = true;
        }
    
        try {
          BidirectionalScrollPosition pos = controller.position;
          pos.setMinMaxExtent(
              -negativeItemCount * ITEM_HEIGHT, itemCount * ITEM_HEIGHT);
        } catch (error) {
          print(error.toString());
        }
        if (rebuildNecessary) {
          _rebuild();
        }
    
        oldScrollPosition = controller.position.pixels;
      }
    }