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

1

5

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

Example:

  • There are 60000 items in the backend database which can be theoretically displayed.
  • First I want to display the items 100..120
  • From these indices I want to be able to scroll up and down while lazy loading the new items

Things to consider:

  • The top and bottom edge (current index < 0 or > 60000) should be bouncing if reached while scrolling

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://mcmap.net/q/194557/-flutter-listview-lazy-loading 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.

Glynis answered 17/12, 2019 at 10:36 Comment(10)
use MapCache.lru - the docs say: "All access to a Cache is asynchronous because many implementations will store their entries in remote systems, isolates, or otherwise have to do async IO to read and write." - use it in the ListView.builder - that way you will have a small "window" with cached values based on your async remote dataDormancy
That has been fast, thanks! This may help caching items, but from my perspective is not solving the issue. Think about ListView.builder: if scrolling up, the indices 1..x will be overridden by new items and x..x*2 will be the old items. This will result in a „jump“ or a not fluent scroll within the listview. Here it would help to enable the „reverse“ option in builder. Anyhow, then the other scroll direction will not be fluent anymore.Glynis
Basically what you want is a ListView on both direction with a builder constructor?Huesman
Exactly - in both directions with lazy load and a top / bottom detection (=scroll boundaries).Glynis
If you scroll in the reverse direction (indices getting lower and lower) the builder index is called correctly, for example: 100, 99, 98 etcDormancy
I updated the question with an example to make things clearer. @Dormancy You're right. To achieve this, e.g. an IndexedListView can be used. Anyhow, if IndexedListView ist initializes with an ItemCount of e.g. 60000, IndexedListView tries to really build all of these items. In addition there are scroll boundaries here.Glynis
pastebin.com/rveZ6Bus here the most simple "load individual data per item" is used but in real world the loading is paginated so the loaded data can be used by several adjacent itemsDormancy
Thanks, just tested it. But I see still the issue that in this case lazy loading also just happens in the down direction. As you can read in my question, a typical use case in my app is that the user just starts right in the middle of the list and from here lazy loading to the top and bottom should work. Any idea how to solve this?Glynis
lazy loading works in both directions - see the cache size (200): if you scroll to item 300 for example and then go back you will see that items 100, 99, 98, ... 0 are loaded again since they are not in the cacheDormancy
You‘re right, this may work if I manually scroll to item 300. Automatic scrolling without knowing the item heights won’t be possible in my case. To talk about a solution: What I need is a listview with negative and positive indices with a separate itemcount for the positive and negative ones in order to achieve scrolling to stop at top and bottom. IndexedListView is solving this issue similar but doesn’t provide scroll boundaries.Glynis
G
5

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;
  }
}
Glynis answered 22/12, 2019 at 13:53 Comment(4)
I tried your solution, but it didn't work in my case. I keep getting 'minScrollExtent' was called on null. I need a list that can scroll to the top and to the bottom, not infinitely, since I already know how many items I have, its just that Its a list of months, where the current months is always loaded first. I'm starting on flutter and I'm a little lost on what's the best approach.. Any suggestion?Disappearance
Thanks for your comment! I just reproduced your mentioned bug. It should be fixed with the updated answer.Glynis
It needs a static height on the items why?Erdei
Thanks for the bidirectional_listview. In the library how to start the display from a certain point of the list i.e. item no. 100(considering list length is -200 to +200). ThanksDanyelledanyette

© 2022 - 2024 — McMap. All rights reserved.