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;
}
}
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 theListView.builder
- that way you will have a small "window" with cached values based on your async remote data – Dormancybuilder
constructor? – Huesman