Merge pull request #1519 from GetStream/feat/update-spl

This commit is contained in:
Sahil Kumar
2023-05-03 17:35:58 +05:30
committed by GitHub
15 changed files with 2437 additions and 468 deletions
@@ -52,6 +52,8 @@
), ),
``` ```
- Added `StreamMessageListView.shrinkWrap` to allow users to shrink wrap the message list view.
🔄 Changed 🔄 Changed
- Deprecated `MessageTheme.linkBackgroundColor` in favor of `MessageTheme.urlAttachmentBackgroundColor`. - Deprecated `MessageTheme.linkBackgroundColor` in favor of `MessageTheme.urlAttachmentBackgroundColor`.
@@ -11,6 +11,7 @@ import 'package:stream_chat_flutter/scrollable_positioned_list/src/indexed_key.d
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart';
/// A list of widgets similar to [ListView], except scroll control /// A list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset. /// and position reporting is based on index rather than pixel offset.
@@ -35,28 +36,20 @@ class PositionedList extends StatefulWidget {
this.alignment = 0, this.alignment = 0,
this.scrollDirection = Axis.vertical, this.scrollDirection = Axis.vertical,
this.reverse = false, this.reverse = false,
this.shrinkWrap = false,
this.physics, this.physics,
this.padding, this.padding,
this.cacheExtent, this.cacheExtent,
this.semanticChildCount, this.semanticChildCount,
this.findChildIndexCallback,
this.addSemanticIndexes = true, this.addSemanticIndexes = true,
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true, this.addAutomaticKeepAlives = true,
this.keyboardDismissBehavior, this.findChildIndexCallback,
}) : assert((positionedIndex == 0) || (positionedIndex < itemCount), this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
'positionedIndex cannot be 0 and must be smaller than itemCount'); }) : assert(
(positionedIndex == 0) || (positionedIndex < itemCount),
/// Called to find the new index of a child based on its key in case of 'positionedIndex must be 0 or a value less than itemCount',
/// reordering. );
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// Number of items the [itemBuilder] can produce. /// Number of items the [itemBuilder] can produce.
final int itemCount; final int itemCount;
@@ -98,6 +91,15 @@ class PositionedList extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool reverse; final bool reverse;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input. /// How the scroll view should respond to user input.
/// ///
/// For example, determines how the scroll view continues to animate after the /// For example, determines how the scroll view continues to animate after the
@@ -132,9 +134,22 @@ class PositionedList extends StatefulWidget {
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives; final bool addAutomaticKeepAlives;
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will /// Called to find the new index of a child based on its key in case of reordering.
/// dismiss the keyboard automatically. ///
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; /// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order of children returned from the children builder changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
///
/// See [SliverChildBuilderDelegate.findChildIndexCallback].
final ChildIndexGetter? findChildIndexCallback;
/// Defines how this [ScrollView] will dismiss the keyboard automatically.
///
/// See [ScrollView.keyboardDismissBehavior].
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
@override @override
State<StatefulWidget> createState() => _PositionedListState(); State<StatefulWidget> createState() => _PositionedListState();
@@ -175,12 +190,13 @@ class _PositionedListState extends State<PositionedList> {
anchor: widget.alignment, anchor: widget.alignment,
center: _centerKey, center: _centerKey,
controller: scrollController, controller: scrollController,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: widget.reverse,
cacheExtent: widget.cacheExtent, cacheExtent: widget.cacheExtent,
physics: widget.physics, physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
semanticChildCount: widget.semanticChildCount ?? widget.itemCount, semanticChildCount: widget.semanticChildCount ?? widget.itemCount,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
slivers: <Widget>[ slivers: <Widget>[
if (widget.positionedIndex > 0) if (widget.positionedIndex > 0)
SliverPadding( SliverPadding(
@@ -196,9 +212,9 @@ class _PositionedListState extends State<PositionedList> {
? widget.positionedIndex ? widget.positionedIndex
: widget.positionedIndex * 2, : widget.positionedIndex * 2,
addSemanticIndexes: false, addSemanticIndexes: false,
findChildIndexCallback: widget.findChildIndexCallback,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -213,10 +229,10 @@ class _PositionedListState extends State<PositionedList> {
index + widget.positionedIndex * 2, index + widget.positionedIndex * 2,
), ),
childCount: widget.itemCount != 0 ? 1 : 0, childCount: widget.itemCount != 0 ? 1 : 0,
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false, addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -234,10 +250,10 @@ class _PositionedListState extends State<PositionedList> {
childCount: widget.separatorBuilder == null childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1 ? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1), : 2 * (widget.itemCount - widget.positionedIndex - 1),
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false, addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
findChildIndexCallback: widget.findChildIndexCallback,
), ),
), ),
), ),
@@ -319,25 +335,33 @@ class _PositionedListState extends State<PositionedList> {
if (!updateScheduled) { if (!updateScheduled) {
updateScheduled = true; updateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (registeredElements.value == null) { final elements = registeredElements.value;
if (elements == null) {
updateScheduled = false; updateScheduled = false;
return; return;
} }
final positions = <ItemPosition>[]; final positions = <ItemPosition>[];
RenderViewport? viewport; RenderViewportBase? viewport;
for (final element in registeredElements.value!) { for (final element in elements) {
final box = element.renderObject as RenderBox?; final box = element.renderObject! as RenderBox;
viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?;
if (viewport == null || box == null) { var anchor = 0.0;
break; if (viewport is RenderViewport) {
anchor = viewport.anchor;
} }
final key = element.widget.key as IndexedKey;
if (viewport is CustomRenderViewport) {
anchor = viewport.anchor;
}
final key = element.widget.key! as IndexedKey;
// Skip this element if `box` has never been laid out.
if (!box.hasSize) continue;
if (widget.scrollDirection == Axis.vertical) { if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport.getOffsetToReveal(box, 0).offset; final reveal = viewport!.getOffsetToReveal(box, 0).offset;
if (!reveal.isFinite) continue; if (!reveal.isFinite) continue;
final itemOffset = reveal - final itemOffset =
viewport.offset.pixels + reveal - viewport.offset.pixels + anchor * viewport.size.height;
viewport.anchor * viewport.size.height;
positions.add(ItemPosition( positions.add(ItemPosition(
index: key.index, index: key.index,
itemLeadingEdge: itemOffset.round() / itemLeadingEdge: itemOffset.round() /
@@ -348,6 +372,7 @@ class _PositionedListState extends State<PositionedList> {
} else { } else {
final itemOffset = final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx; box.localToGlobal(Offset.zero, ancestor: viewport).dx;
if (!itemOffset.isFinite) continue;
positions.add(ItemPosition( positions.add(ItemPosition(
index: key.index, index: key.index,
itemLeadingEdge: (widget.reverse itemLeadingEdge: (widget.reverse
@@ -5,13 +5,14 @@
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/wrapping.dart';
/// {@template custom_scroll_view} /// {@template unbounded_custom_scroll_view}
/// A version of [CustomScrollView] that does not constrict the extents /// A version of [CustomScrollView] that allows does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information. /// to be within 0 and 1. See [CustomScrollView] for more information.
/// {@endtemplate} /// {@endtemplate}
class UnboundedCustomScrollView extends CustomScrollView { class UnboundedCustomScrollView extends CustomScrollView {
/// {@macro custom_scroll_view} /// {@macro unbounded_custom_scroll_view}
const UnboundedCustomScrollView({ const UnboundedCustomScrollView({
super.key, super.key,
super.scrollDirection, super.scrollDirection,
@@ -19,19 +20,19 @@ class UnboundedCustomScrollView extends CustomScrollView {
super.controller, super.controller,
super.primary, super.primary,
super.physics, super.physics,
super.shrinkWrap, bool shrinkWrap = false,
super.center, super.center,
double anchor = 0.0, double anchor = 0.0,
super.cacheExtent, super.cacheExtent,
super.slivers, super.slivers,
super.semanticChildCount, super.semanticChildCount,
super.dragStartBehavior, super.dragStartBehavior,
ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, super.keyboardDismissBehavior,
}) : _anchor = anchor, }) : _shrinkWrap = shrinkWrap,
super( _anchor = anchor,
keyboardDismissBehavior: keyboardDismissBehavior ?? super(shrinkWrap: false);
ScrollViewKeyboardDismissBehavior.manual,
); final bool _shrinkWrap;
// [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so
// we need our own version. // we need our own version.
@@ -49,11 +50,14 @@ class UnboundedCustomScrollView extends CustomScrollView {
AxisDirection axisDirection, AxisDirection axisDirection,
List<Widget> slivers, List<Widget> slivers,
) { ) {
if (shrinkWrap) { if (_shrinkWrap) {
return ShrinkWrappingViewport( return CustomShrinkWrappingViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
offset: offset, offset: offset,
slivers: slivers, slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
); );
} }
return UnboundedViewport( return UnboundedViewport(
@@ -37,6 +37,7 @@ class ScrollablePositionedList extends StatefulWidget {
required this.itemBuilder, required this.itemBuilder,
super.key, super.key,
this.itemScrollController, this.itemScrollController,
this.shrinkWrap = false,
ItemPositionsListener? itemPositionsListener, ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0, this.initialScrollIndex = 0,
this.initialAlignment = 0, this.initialAlignment = 0,
@@ -50,7 +51,7 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
separatorBuilder = null; separatorBuilder = null;
@@ -61,6 +62,7 @@ class ScrollablePositionedList extends StatefulWidget {
required this.itemBuilder, required this.itemBuilder,
required IndexedWidgetBuilder this.separatorBuilder, required IndexedWidgetBuilder this.separatorBuilder,
super.key, super.key,
this.shrinkWrap = false,
this.itemScrollController, this.itemScrollController,
ItemPositionsListener? itemPositionsListener, ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0, this.initialScrollIndex = 0,
@@ -75,24 +77,9 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?; }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?;
/// Called to find the new index of a child based on its key in case of
/// reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will
/// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior;
/// Number of items the [itemBuilder] can produce. /// Number of items the [itemBuilder] can produce.
final int itemCount; final int itemCount;
@@ -131,6 +118,15 @@ class ScrollablePositionedList extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool reverse; final bool reverse;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input. /// How the scroll view should respond to user input.
/// ///
/// For example, determines how the scroll view continues to animate after the /// For example, determines how the scroll view continues to animate after the
@@ -171,6 +167,23 @@ class ScrollablePositionedList extends StatefulWidget {
/// cache extent. /// cache extent.
final double? minCacheExtent; final double? minCacheExtent;
/// Called to find the new index of a child based on its key in case of reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order of children returned from the children builder changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
///
/// See [SliverChildBuilderDelegate.findChildIndexCallback].
final ChildIndexGetter? findChildIndexCallback;
/// Defines how this [ScrollView] will dismiss the keyboard automatically.
///
/// See [ScrollView.keyboardDismissBehavior].
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
@override @override
State<StatefulWidget> createState() => _ScrollablePositionedListState(); State<StatefulWidget> createState() => _ScrollablePositionedListState();
} }
@@ -233,11 +246,15 @@ class ItemScrollController {
Curve curve = Curves.linear, Curve curve = Curves.linear,
List<double> opacityAnimationWeights = const [40, 20, 40], List<double> opacityAnimationWeights = const [40, 20, 40],
}) { }) {
assert(_scrollableListState != null, '_scrollableListState cannot be null'); assert(
assert(opacityAnimationWeights.length == 3, _scrollableListState != null,
'opacityAnimationWeights.length is not equal to 3'); '''ScrollController must be attached to a ScrollablePositionedList to scroll.''',
assert(duration > Duration.zero, );
'duration needs to be bigger than Duration.zero'); assert(
opacityAnimationWeights.length == 3,
'opacityAnimationWeights must have exactly three elements.',
);
assert(duration > Duration.zero, 'Duration must be greater than zero.');
return _scrollableListState!._scrollTo( return _scrollableListState!._scrollTo(
index: index, index: index,
alignment: alignment, alignment: alignment,
@@ -249,7 +266,9 @@ class ItemScrollController {
void _attach(_ScrollablePositionedListState scrollableListState) { void _attach(_ScrollablePositionedListState scrollableListState) {
assert( assert(
_scrollableListState == null, '_scrollableListState needs to be null'); _scrollableListState == null,
'''ScrollController must not be attached to multiple ScrollablePositionedLists.''',
);
_scrollableListState = scrollableListState; _scrollableListState = scrollableListState;
} }
@@ -273,11 +292,12 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
bool _isTransitioning = false; bool _isTransitioning = false;
AnimationController? _animationController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final ItemPosition? initialPosition = final initialPosition = PageStorage.of(context).readState(context);
PageStorage.of(context).readState(context);
primary primary
..target = initialPosition?.index ?? widget.initialScrollIndex ..target = initialPosition?.index ?? widget.initialScrollIndex
..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment; ..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
@@ -301,6 +321,7 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
.removeListener(_updatePositions); .removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions); .removeListener(_updatePositions);
_animationController?.dispose();
super.dispose(); super.dispose();
} }
@@ -329,84 +350,90 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
} }
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) {
builder: (context, constraints) { return LayoutBuilder(
final cacheExtent = _cacheExtent(constraints); builder: (context, constraints) {
return GestureDetector( final cacheExtent = _cacheExtent(constraints);
onPanDown: (_) => _stopScroll(canceled: true), return GestureDetector(
excludeFromSemantics: true, onPanDown: (_) => _stopScroll(canceled: true),
child: Stack( excludeFromSemantics: true,
children: <Widget>[ child: Stack(
children: <Widget>[
PostMountCallback(
key: primary.key,
callback: startAnimationCallback,
child: FadeTransition(
opacity: ReverseAnimation(opacity),
child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning,
child: PositionedList(
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
positionedIndex: primary.target,
controller: primary.scrollController,
itemPositionsNotifier: primary.itemPositionsNotifier,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: primary.alignment,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
findChildIndexCallback: widget.findChildIndexCallback,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
),
),
),
),
if (_isTransitioning)
PostMountCallback( PostMountCallback(
key: primary.key, key: secondary.key,
callback: startAnimationCallback, callback: startAnimationCallback,
child: FadeTransition( child: FadeTransition(
opacity: ReverseAnimation(opacity), opacity: opacity,
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning, onNotification: (_) => false,
child: PositionedList( child: PositionedList(
keyboardDismissBehavior: widget.keyboardDismissBehavior,
itemBuilder: widget.itemBuilder, itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder, separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount, itemCount: widget.itemCount,
positionedIndex: primary.target, itemPositionsNotifier: secondary.itemPositionsNotifier,
controller: primary.scrollController, positionedIndex: secondary.target,
itemPositionsNotifier: primary.itemPositionsNotifier, controller: secondary.scrollController,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: widget.reverse,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
alignment: primary.alignment, alignment: secondary.alignment,
physics: widget.physics, physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes, addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount, semanticChildCount: widget.semanticChildCount,
padding: widget.padding, padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives, addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries, addRepaintBoundaries: widget.addRepaintBoundaries,
findChildIndexCallback: widget.findChildIndexCallback, findChildIndexCallback: widget.findChildIndexCallback,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
), ),
), ),
), ),
), ),
if (_isTransitioning) ],
PostMountCallback( ),
key: secondary.key, );
callback: startAnimationCallback, },
child: FadeTransition( );
opacity: opacity, }
child: NotificationListener<ScrollNotification>(
onNotification: (_) => false,
child: PositionedList(
keyboardDismissBehavior:
widget.keyboardDismissBehavior,
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
itemPositionsNotifier:
secondary.itemPositionsNotifier,
positionedIndex: secondary.target,
controller: secondary.scrollController,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: secondary.alignment,
physics: widget.physics,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
],
),
);
},
);
double _cacheExtent(BoxConstraints constraints) => max( double _cacheExtent(BoxConstraints constraints) => max(
constraints.maxHeight * _screenScrollCount, (widget.scrollDirection == Axis.vertical
? constraints.maxHeight
: constraints.maxWidth) *
_screenScrollCount,
widget.minCacheExtent ?? 0, widget.minCacheExtent ?? 0,
); );
@@ -434,16 +461,19 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
index = widget.itemCount - 1; index = widget.itemCount - 1;
} }
if (_isTransitioning) { if (_isTransitioning) {
final scrollCompleter = Completer<void>();
_stopScroll(canceled: true); _stopScroll(canceled: true);
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) async {
_startScroll( await _startScroll(
index: index, index: index,
alignment: alignment, alignment: alignment,
duration: duration, duration: duration,
curve: curve, curve: curve,
opacityAnimationWeights: opacityAnimationWeights, opacityAnimationWeights: opacityAnimationWeights,
); );
scrollCompleter.complete();
}); });
await scrollCompleter.future;
} else { } else {
await _startScroll( await _startScroll(
index: index, index: index,
@@ -486,10 +516,11 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
startAnimationCallback = () { startAnimationCallback = () {
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
startAnimationCallback = () {}; startAnimationCallback = () {};
_animationController?.dispose();
opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( _animationController =
AnimationController(vsync: this, duration: duration)..forward(), AnimationController(vsync: this, duration: duration)..forward();
); opacity.parent = _opacityAnimation(opacityAnimationWeights)
.animate(_animationController!);
secondary.scrollController.jumpTo(-direction * secondary.scrollController.jumpTo(-direction *
(_screenScrollCount * (_screenScrollCount *
primary.scrollController.position.viewportDimension - primary.scrollController.position.viewportDimension -
@@ -532,17 +563,19 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
} }
} }
setState(() { if (mounted) {
if (opacity.value >= 0.5) { setState(() {
// Secondary [ListView] is more visible than the primary; make it the if (opacity.value >= 0.5) {
// new primary. // Secondary [ListView] is more visible than the primary; make it the
final temp = primary; // new primary.
primary = secondary; final temp = primary;
secondary = temp; primary = secondary;
} secondary = temp;
_isTransitioning = false; }
opacity.parent = const AlwaysStoppedAnimation<double>(0); _isTransitioning = false;
}); opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
} }
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) { Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
// ignore_for_file: lines_longer_than_80_chars
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@@ -15,7 +13,7 @@ import 'package:flutter/widgets.dart';
/// Version of [Viewport] with some modifications to how extents are /// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport] /// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information. /// for more information.
/// description /// {@endtemplate}
class UnboundedViewport extends Viewport { class UnboundedViewport extends Viewport {
/// {@macro unbounded_viewport} /// {@macro unbounded_viewport}
UnboundedViewport({ UnboundedViewport({
@@ -37,15 +35,16 @@ class UnboundedViewport extends Viewport {
double get anchor => _anchor; double get anchor => _anchor;
@override @override
RenderViewport createRenderObject(BuildContext context) => RenderViewport createRenderObject(BuildContext context) {
UnboundedRenderViewport( return UnboundedRenderViewport(
axisDirection: axisDirection, axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ?? crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection), Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor, anchor: anchor,
offset: offset, offset: offset,
cacheExtent: cacheExtent, cacheExtent: cacheExtent,
); );
}
} }
/// A render object that is bigger on the inside. /// A render object that is bigger on the inside.
@@ -137,14 +136,20 @@ class UnboundedRenderViewport extends RenderViewport {
@override @override
void performLayout() { void performLayout() {
if (center == null) { if (center == null) {
assert(firstChild == null, 'firstChild cannot be null'); assert(
firstChild == null,
'A RenderViewport with no center render object must have no children.',
);
_minScrollExtent = 0.0; _minScrollExtent = 0.0;
_maxScrollExtent = 0.0; _maxScrollExtent = 0.0;
_hasVisualOverflow = false; _hasVisualOverflow = false;
offset.applyContentDimensions(0, 0); offset.applyContentDimensions(0, 0);
return; return;
} }
assert(center!.parent == this, 'center.parent cannot be equal to this'); assert(
center!.parent == this,
'''The "center" property of a RenderViewport must be a child of the viewport.''',
);
late double mainAxisExtent; late double mainAxisExtent;
late double crossAxisExtent; late double crossAxisExtent;
@@ -186,7 +191,7 @@ class UnboundedRenderViewport extends RenderViewport {
} while (count < _maxLayoutCycles); } while (count < _maxLayoutCycles);
assert(() { assert(() {
if (count >= _maxLayoutCycles) { if (count >= _maxLayoutCycles) {
assert(count != 1, 'count not equal to 1'); assert(count != 1);
throw FlutterError( throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n' 'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their ' 'RenderViewport render objects, during layout, can retry if either their '
@@ -207,7 +212,7 @@ class UnboundedRenderViewport extends RenderViewport {
); );
} }
return true; return true;
}(), 'count needs to be bigger than _maxLayoutCycles'); }());
} }
double _attemptLayout( double _attemptLayout(
@@ -215,11 +220,11 @@ class UnboundedRenderViewport extends RenderViewport {
double crossAxisExtent, double crossAxisExtent,
double correctedOffset, double correctedOffset,
) { ) {
assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); assert(!mainAxisExtent.isNaN, 'The main axis extent cannot be NaN.');
assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); assert(mainAxisExtent >= 0.0, 'The main axis extent cannot be negative.');
assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); assert(crossAxisExtent.isFinite, 'The cross axis extent must be finite.');
assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); assert(crossAxisExtent >= 0.0, 'The cross axis extent cannot be negative.');
assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); assert(correctedOffset.isFinite, 'The corrected offset must be finite.');
_minScrollExtent = 0.0; _minScrollExtent = 0.0;
_maxScrollExtent = 0.0; _maxScrollExtent = 0.0;
_hasVisualOverflow = false; _hasVisualOverflow = false;
File diff suppressed because it is too large Load Diff
@@ -71,7 +71,7 @@ enum SpacingType {
/// A [StreamChannel] ancestor widget is required in order to provide the /// A [StreamChannel] ancestor widget is required in order to provide the
/// information about the channels. /// information about the channels.
/// ///
/// Uses a [ListView.custom] to render the list of channels. /// Uses a [ScrollablePositionedList] to render the list of channels.
/// ///
/// The UI is rendered based on the first ancestor of type [StreamChatTheme]. /// The UI is rendered based on the first ancestor of type [StreamChatTheme].
/// Modify it to change the widget's appearance. /// Modify it to change the widget's appearance.
@@ -88,8 +88,10 @@ class StreamMessageListView extends StatefulWidget {
this.threadBuilder, this.threadBuilder,
this.onThreadTap, this.onThreadTap,
this.dateDividerBuilder, this.dateDividerBuilder,
this.scrollPhysics = // we need to use ClampingScrollPhysics to avoid the list view to bounce
const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading // when we are at the either end of the list view and try to use 'animateTo'
// to animate in the same direction.
this.scrollPhysics = const ClampingScrollPhysics(),
this.initialScrollIndex, this.initialScrollIndex,
this.initialAlignment, this.initialAlignment,
this.scrollController, this.scrollController,
@@ -113,6 +115,7 @@ class StreamMessageListView extends StatefulWidget {
this.unreadMessagesSeparatorBuilder, this.unreadMessagesSeparatorBuilder,
this.messageListController, this.messageListController,
this.reverse = true, this.reverse = true,
this.shrinkWrap = false,
this.paginationLimit = 20, this.paginationLimit = 20,
this.paginationLoadingIndicatorBuilder, this.paginationLoadingIndicatorBuilder,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
@@ -133,6 +136,14 @@ class StreamMessageListView extends StatefulWidget {
/// See [ScrollView.reverse]. /// See [ScrollView.reverse].
final bool reverse; final bool reverse;
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// Limit used during pagination /// Limit used during pagination
final int paginationLimit; final int paginationLimit;
@@ -548,6 +559,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
physics: widget.scrollPhysics, physics: widget.scrollPhysics,
itemScrollController: _scrollController, itemScrollController: _scrollController,
reverse: widget.reverse, reverse: widget.reverse,
shrinkWrap: widget.shrinkWrap,
itemCount: itemCount, itemCount: itemCount,
findChildIndexCallback: (Key key) { findChildIndexCallback: (Key key) {
final indexedKey = key as IndexedKey; final indexedKey = key as IndexedKey;
@@ -555,6 +567,10 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
if (valueKey != null) { if (valueKey != null) {
final index = messagesIndex[valueKey.value]; final index = messagesIndex[valueKey.value];
if (index != null) { if (index != null) {
// The calculation is as follows:
// * Add 2 to the index retrieved to account for the footer and the bottom loader.
// * Multiply the result by 2 to account for the separators between each pair of items.
// * Subtract 1 to adjust for the 0-based indexing of the list view.
return ((index + 2) * 2) - 1; return ((index + 2) * 2) - 1;
} }
} }
@@ -2,13 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 100.0;
const screenWidth = 400.0; const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0; const itemWidth = screenWidth / 10.0;
const itemCount = 500; const itemCount = 500;
@@ -46,6 +45,11 @@ void main() {
); );
} }
final fadeTransitionFinder = find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition),
);
testWidgets('List positioned with 0 at left', (WidgetTester tester) async { testWidgets('List positioned with 0 at left', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
@@ -172,7 +176,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 100')).dx, 0); expect(tester.getTopLeft(find.text('Item 100')).dx, 0);
expect(tester.getBottomRight(find.text('Item 109')).dy, screenWidth); expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight);
expect( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
@@ -196,6 +200,31 @@ void main() {
1); 1);
}); });
testWidgets('Scroll to 20 without fading', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
var fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
final initialOpacity = fadeTransition.opacity;
unawaited(
itemScrollController.scrollTo(index: 20, duration: scrollDuration));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle();
expect(find.text('Item 14'), findsNothing);
expect(find.text('Item 20'), findsOneWidget);
});
testWidgets('padding test - centered sliver at left', testWidgets('padding test - centered sliver at left',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -360,4 +360,57 @@ void main() {
.itemTrailingEdge, .itemTrailingEdge,
1); 1);
}); });
testWidgets('Does not crash when updated offscreen',
(WidgetTester tester) async {
late StateSetter setState;
var updated = false;
// There's 0 relayout boundaries in this subtree.
final widget = StatefulBuilder(builder: (context, stateSetter) {
setState = stateSetter;
return Positioned(
left: 0,
right: 0,
child: PositionedList(
shrinkWrap: true,
itemCount: 1,
// When `updated` becomes true this line inserts a
// RenderIndexedSemantics to the render tree.
addSemanticIndexes: updated,
itemBuilder: (context, index) => const SizedBox(height: itemHeight),
));
});
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) => widget, maintainState: true),
],
),
));
// Insert a new opaque OverlayEntry that would prevent the first
// OverlayEntry from doing re-layout. Since there's no relayout boundaries
// in the first OverlayEntry, no dirty RenderObjects in its render subtree
// can update layout.
final newOverlay = OverlayEntry(
builder: (context) => const SizedBox.expand(),
opaque: true,
);
tester.state<OverlayState>(find.byType(Overlay)).insert(newOverlay);
await tester.pump();
// Update the list item's render tree. A new RenderObjectElement is
// inflated, registeredElement.renderObject will point to this new
// RenderObjectElement's RenderObject (RenderIndexedSemantics), which has
// never been laid out.
setState(() {
updated = true;
});
await tester.pump();
expect(tester.takeException(), isNull);
});
} }
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 400.0;
@@ -2,12 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
@@ -48,8 +48,10 @@ void main() {
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'''index needs to be bigger or equal to 0 and smallert than itemCount -1'''); index >= 0 && index <= itemCount - 1,
'index must be in the range of 0 to itemCount - 1',
);
return SizedBox( return SizedBox(
height: height:
variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight,
@@ -71,6 +73,11 @@ void main() {
); );
} }
final fadeTransitionFinder = find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition),
);
testWidgets('List positioned with 0 at top', (WidgetTester tester) async { testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
@@ -394,11 +401,7 @@ void main() {
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener); itemPositionsListener: itemPositionsListener);
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -407,11 +410,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -456,10 +455,6 @@ void main() {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester, itemScrollController: itemScrollController); await setUpWidgetTest(tester, itemScrollController: itemScrollController);
final fadeTransitionFinder = find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition));
unawaited( unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration)); itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pump(); await tester.pump();
@@ -533,26 +528,14 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(0, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(0, 0.01));
await tester.pump(scrollDuration + scrollDurationTolerance); await tester.pump(scrollDuration + scrollDurationTolerance);
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(1, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(1, 0.01));
expect(find.text('Item 0'), findsOneWidget); expect(find.text('Item 0'), findsOneWidget);
expect(tester.getTopLeft(find.text('Item 0')).dy, 0); expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
@@ -610,15 +593,9 @@ void main() {
expect(tester.getTopLeft(find.text('Item 10')).dy, 0); expect(tester.getTopLeft(find.text('Item 10')).dy, 0);
expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight);
expect( expect(
tester tester.widget<FadeTransition>(fadeTransitionFinder.last).opacity.value,
.widget<FadeTransition>(find closeTo(0.5, 0.01),
.descendant( );
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last)
.opacity
.value,
closeTo(0.5, 0.01));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
@@ -899,11 +876,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 9')).dy, 0); expect(tester.getTopLeft(find.text('Item 9')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(find final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity.value, 1.0); expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -923,21 +896,12 @@ void main() {
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 10')).dy, 0); expect(tester.getTopLeft(find.text('Item 10')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(find final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity.value, 1.0); expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
final fadeTransitionFinder = find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition),
);
testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async { testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester, itemScrollController: itemScrollController); await setUpWidgetTest(tester, itemScrollController: itemScrollController);
@@ -1022,14 +986,13 @@ void main() {
itemScrollController.scrollTo(index: 0, duration: scrollDuration)); itemScrollController.scrollTo(index: 0, duration: scrollDuration));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); await tester.pump(scrollDuration ~/ 2);
await tester.tap(find.byType(ScrollablePositionedList)); await tester.tap(find.byType(ScrollablePositionedList));
await tester.pump(); await tester.pump();
expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); expect(tester.getTopLeft(find.text('Item 90')).dy, 0);
final fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder); expect(fadeTransitionFinder, findsNWidgets(1));
expect(fadeTransition.opacity.value, 1.0);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
@@ -1098,6 +1061,34 @@ void main() {
expect(find.text('Item 100'), findsNothing); expect(find.text('Item 100'), findsNothing);
}); });
testWidgets("Second scroll future doesn't complete until scroll is done",
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester, itemScrollController: itemScrollController);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration ~/ 2);
final scrollFuture2 =
itemScrollController.scrollTo(index: 250, duration: scrollDuration);
var futureComplete = false;
unawaited(scrollFuture2.then((_) => futureComplete = true));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration ~/ 2);
expect(futureComplete, isFalse);
await tester.pumpAndSettle();
expect(futureComplete, isTrue);
});
testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1145,34 +1136,35 @@ void main() {
}, skip: true); }, skip: true);
testWidgets( testWidgets(
'''Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back''', 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, await setUpWidgetTest(tester,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener); itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 400, alignment: 1); itemScrollController.jumpTo(index: 400, alignment: 1);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final listFinder = find.byType(ScrollablePositionedList); final listFinder = find.byType(ScrollablePositionedList);
await tester.drag(listFinder, const Offset(0, -screenHeight)); await tester.drag(listFinder, const Offset(0, -screenHeight));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
unawaited(itemScrollController.scrollTo( unawaited(itemScrollController.scrollTo(
index: 100, alignment: 1, duration: scrollDuration)); index: 100, alignment: 1, duration: scrollDuration));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
unawaited(itemScrollController.scrollTo( unawaited(itemScrollController.scrollTo(
index: 400, alignment: 1, duration: scrollDuration)); index: 400, alignment: 1, duration: scrollDuration));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final itemFinder = find.text('Item 399'); final itemFinder = find.text('Item 399');
expect(itemFinder, findsOneWidget); expect(itemFinder, findsOneWidget);
expect(tester.getBottomLeft(itemFinder).dy, screenHeight); expect(tester.getBottomLeft(itemFinder).dy, screenHeight);
}); },
);
testWidgets('physics', (WidgetTester tester) async { testWidgets('physics', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1664,70 +1656,71 @@ void main() {
}); });
testWidgets( testWidgets(
'''Maintain programmatic and user position (9 half way off top) in page view''', 'Maintain programmatic and user position (9 half way off top) in page view',
(WidgetTester tester) async { (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create(); final itemPositionsListener = ItemPositionsListener.create();
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
tester.binding.window.devicePixelRatioTestValue = 1.0; tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue = tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight); const Size(screenWidth, screenHeight);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: PageView( home: PageView(
children: [ children: [
KeyedSubtree( KeyedSubtree(
key: const PageStorageKey('key'), key: const PageStorageKey('key'),
child: ScrollablePositionedList.builder( child: ScrollablePositionedList.builder(
itemCount: defaultItemCount, itemCount: defaultItemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
),
itemPositionsListener: itemPositionsListener,
), ),
itemPositionsListener: itemPositionsListener,
), ),
), const Center(
const Center( child: Text('Test'),
child: Text('Test'), )
) ],
], ),
), ),
), );
);
itemScrollController.jumpTo(index: 9); itemScrollController.jumpTo(index: 9);
await tester.pump(); await tester.pump();
expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight);
await tester.drag( await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); find.byType(ScrollablePositionedList), const Offset(0, -itemHeight));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy;
expect(item9Bottom, lessThan(itemHeight)); expect(item9Bottom, lessThan(itemHeight));
await tester.drag(find.byType(PageView), const Offset(-500, 0)); await tester.drag(find.byType(PageView), const Offset(-500, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(500, 0)); await tester.drag(find.byType(PageView), const Offset(500, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom);
expect( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9) .firstWhere((position) => position.index == 9)
.itemLeadingEdge, .itemLeadingEdge,
-(itemHeight / screenHeight) / 2); -(itemHeight / screenHeight) / 2);
expect( expect(
itemPositionsListener.itemPositions.value itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9) .firstWhere((position) => position.index == 9)
.itemTrailingEdge, .itemTrailingEdge,
(itemHeight / screenHeight) / 2); (itemHeight / screenHeight) / 2);
}); },
);
testWidgets('List with no items', (WidgetTester tester) async { testWidgets('List with no items', (WidgetTester tester) async {
final itemScrollController = ItemScrollController(); final itemScrollController = ItemScrollController();
@@ -1751,21 +1744,24 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: min(100, itemCount), initialScrollIndex: min(100, itemCount),
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount - 1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1795,19 +1791,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: min(100, itemCount - 1), initialScrollIndex: min(100, itemCount - 1),
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount -1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1834,19 +1833,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
initialScrollIndex: itemCount - 1, initialScrollIndex: itemCount - 1,
itemCount: itemCount, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
assert(index >= 0 && index <= itemCount - 1, assert(
'index not bigger than 0 and smaller than itemCount -1'); index >= 0 && index <= itemCount - 1,
return SizedBox( 'index must be in the range of 0 to itemCount - 1',
height: itemHeight, );
child: Text('Item $index'), return SizedBox(
); height: itemHeight,
}, child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -1878,11 +1880,7 @@ void main() {
minCacheExtent: 10, minCacheExtent: 10,
); );
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -1891,11 +1889,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1914,11 +1908,9 @@ void main() {
minCacheExtent: itemHeight * 200, minCacheExtent: itemHeight * 200,
); );
var fadeTransition = tester.widget<FadeTransition>(find var fadeTransition = tester.widget<FadeTransition>(
.descendant( fadeTransitionFinder,
of: find.byType(ScrollablePositionedList), );
matching: find.byType(FadeTransition))
.last);
final initialOpacity = fadeTransition.opacity; final initialOpacity = fadeTransition.opacity;
unawaited( unawaited(
@@ -1927,11 +1919,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(scrollDuration ~/ 2); await tester.pump(scrollDuration ~/ 2);
fadeTransition = tester.widget<FadeTransition>(find fadeTransition = tester.widget<FadeTransition>(fadeTransitionFinder);
.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(FadeTransition))
.last);
expect(fadeTransition.opacity, initialOpacity); expect(fadeTransition.opacity, initialOpacity);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -1965,17 +1953,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
itemCount: 200, child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController, itemCount: 200,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2054,15 +2046,19 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => ScrollablePositionedList.builder( builder: (context, key, child) {
key: key, return ScrollablePositionedList.builder(
itemCount: 10, key: key,
itemScrollController: itemScrollController, itemCount: 10,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
), height: itemHeight,
), child: Text('Item $index'),
);
},
);
},
), ),
), ),
); );
@@ -2084,17 +2080,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: key, valueListenable: key,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
itemCount: 100, child: ScrollablePositionedList.builder(
itemScrollController: itemScrollController, itemCount: 100,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2124,18 +2124,22 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<Key>( home: ValueListenableBuilder<Key>(
valueListenable: containerKey, valueListenable: containerKey,
builder: (context, key, child) => Container( builder: (context, key, child) {
key: key, return Container(
child: ScrollablePositionedList.builder( key: key,
key: scrollKey, child: ScrollablePositionedList.builder(
itemCount: 100, key: scrollKey,
itemScrollController: itemScrollController, itemCount: 100,
itemBuilder: (context, index) => SizedBox( itemScrollController: itemScrollController,
height: itemHeight, itemBuilder: (context, index) {
child: Text('Item $index'), return SizedBox(
height: itemHeight,
child: Text('Item $index'),
);
},
), ),
), );
), },
), ),
), ),
); );
@@ -2166,15 +2170,18 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<ItemScrollController>( home: ValueListenableBuilder<ItemScrollController>(
valueListenable: itemScrollControllerListenable, valueListenable: itemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
); );
@@ -2215,29 +2222,35 @@ void main() {
Expanded( Expanded(
child: ValueListenableBuilder<ItemScrollController>( child: ValueListenableBuilder<ItemScrollController>(
valueListenable: topItemScrollControllerListenable, valueListenable: topItemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
Expanded( Expanded(
child: ValueListenableBuilder<ItemScrollController>( child: ValueListenableBuilder<ItemScrollController>(
valueListenable: bottomItemScrollControllerListenable, valueListenable: bottomItemScrollControllerListenable,
builder: (context, itemScrollController, child) => builder: (context, itemScrollController, child) {
ScrollablePositionedList.builder( return ScrollablePositionedList.builder(
itemCount: 100, itemCount: 100,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) {
height: itemHeight, return SizedBox(
child: Text('Item $index'), height: itemHeight,
), child: Text('Item $index'),
), );
},
);
},
), ),
), ),
], ],
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
@@ -497,20 +496,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.separated( return ScrollablePositionedList.separated(
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
), ),
separatorBuilder: (context, index) => SizedBox( separatorBuilder: (context, index) => SizedBox(
height: separatorHeight, height: separatorHeight,
child: Text('Separator $index'), child: Text('Separator $index'),
), ),
), );
},
), ),
), ),
); );
@@ -538,20 +538,21 @@ void main() {
MaterialApp( MaterialApp(
home: ValueListenableBuilder<int>( home: ValueListenableBuilder<int>(
valueListenable: itemCount, valueListenable: itemCount,
builder: (context, itemCount, child) => builder: (context, itemCount, child) {
ScrollablePositionedList.separated( return ScrollablePositionedList.separated(
itemCount: itemCount, itemCount: itemCount,
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox( itemBuilder: (context, index) => SizedBox(
height: itemHeight, height: itemHeight,
child: Text('Item $index'), child: Text('Item $index'),
), ),
separatorBuilder: (context, index) => SizedBox( separatorBuilder: (context, index) => SizedBox(
height: separatorHeight, height: separatorHeight,
child: Text('Separator $index'), child: Text('Separator $index'),
), ),
), );
},
), ),
), ),
); );
@@ -2,10 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0; const screenHeight = 400.0;
@@ -0,0 +1,477 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const defaultItemCount = 500;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<void> setUpWidgetTest(
WidgetTester tester, {
int topItem = 0,
Key? key,
ScrollController? scrollController,
double anchor = 0,
int itemCount = defaultItemCount,
bool reverse = false,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
// Use flex layout to ensure that the minimum height is not limited to
// screenHeight.
home: Column(children: [
// Use Constrained to make max height not more than screenHeight
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: screenHeight, maxWidth: screenWidth),
child: PositionedList(
key: key,
itemCount: itemCount,
positionedIndex: topItem,
alignment: anchor,
controller: scrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
itemPositionsNotifier:
itemPositionsNotifier as ItemPositionsNotifier,
shrinkWrap: true,
reverse: reverse,
),
),
]),
),
);
}
testWidgets('short list with shrink wrap', (WidgetTester tester) async {
const itemCount = 5;
const key = Key('short_list');
await setUpWidgetTest(tester, itemCount: itemCount, key: key);
await tester.pump();
expect(
tester.getBottomRight(find.text('Item 4')).dy, itemHeight * itemCount);
expect(find.text('Item 4'), findsOneWidget);
expect(find.text('Item 5'), findsNothing);
final positionList = find.byKey(key);
expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
1.0);
});
testWidgets('List positioned with 0 at top and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 9'), findsOneWidget);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemTrailingEdge,
11 / 10);
});
testWidgets('List positioned with 5 at top and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.pump();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 20 at bottom and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 1);
await tester.pump();
expect(find.text('Item 20'), findsNothing);
expect(find.text('Item 19'), findsOneWidget);
expect(find.text('Item 10'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemLeadingEdge,
9 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
1);
});
testWidgets('List positioned with 20 at halfway and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 0.5);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
0.5);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
0.5 + itemHeight / screenHeight);
});
testWidgets('List positioned with 20 half off top of screen and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester,
topItem: 20, anchor: -(itemHeight / screenHeight) / 2);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
-(itemHeight / screenHeight) / 2);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
(itemHeight / screenHeight) / 2);
});
testWidgets('List positioned with 5 at top then scroll up 2 and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, itemHeight * 2));
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets(
'List positioned with 5 at top then scroll down 1/2 and shrink wrap',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight));
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 / 20);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemLeadingEdge,
17 / 20);
});
testWidgets('List positioned with 0 at top scroll up 5 and shrink wrap',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester, scrollController: scrollController);
await tester.pump();
scrollController.jumpTo(itemHeight * 5);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
});
testWidgets(
'''List positioned with 5 at top then scroll up 2 programatically and shrink wrap''',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(-2 * itemHeight);
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
},
);
testWidgets(
'''List positioned with 5 at top then scroll down 20 programatically and shrink wrap''',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(itemHeight * 20);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 23)
.itemLeadingEdge,
-2 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 24)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 25)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-21 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
-20 / 10);
},
);
testWidgets(
'List positioned with 5 at top and initial scroll offset and shrink wrap',
(WidgetTester tester) async {
final scrollController =
ScrollController(initialScrollOffset: -2 * itemHeight);
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets('short List with reverse and shrink wrap',
(WidgetTester tester) async {
const itemCount = 5;
const key = Key('short_list');
await setUpWidgetTest(tester,
itemCount: itemCount, key: key, reverse: true);
await tester.pump();
expect(find.text('Item 4'), findsOneWidget);
expect(find.text('Item 5'), findsNothing);
expect(
tester.getBottomRight(find.text('Item 0')).dy, itemHeight * itemCount);
expect(tester.getTopLeft(find.text('Item 4')).dy, 0);
final positionList = find.byKey(key);
expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount);
expect(tester.getTopLeft(positionList).dy, 0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
1.0);
});
testWidgets('test nested positioned list', (WidgetTester tester) async {
const itemCount = 50;
const key = Key('short_list');
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
// Use flex layout to ensure that the minimum height is not limited to
// screenHeight.
home: PositionedList(
itemCount: 5,
itemBuilder: (context, index) {
if (index == 0) {
return PositionedList(
key: key,
itemCount: itemCount,
shrinkWrap: true,
itemBuilder: (context, idx) => SizedBox(
height: itemHeight,
child: Text('Item $idx'),
));
} else {
return SizedBox(
height: itemHeight,
child: Text('Item ${itemCount + index - 1}'),
);
}
},
itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier,
),
),
);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 50'), findsNothing);
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
expect(tester.getBottomRight(find.text('Item 9')).dy, screenHeight);
final positionList = find.byKey(key);
expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount);
expect(tester.getTopLeft(positionList).dy, 0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemTrailingEdge,
5.0);
});
}
@@ -0,0 +1,247 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
void main() {
Future<void> setUpWidgetTest(
WidgetTester tester, {
ItemScrollController? itemScrollController,
ItemPositionsListener? itemPositionsListener,
EdgeInsets? padding,
int initialIndex = 0,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
// Use flex layout to ensure that the minimum height is not limited to
// screenHeight.
home: Column(children: [
// Use Constrained to make max height not more than screenHeight
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: screenHeight, maxWidth: screenWidth),
child: ScrollablePositionedList.builder(
itemCount: itemCount,
initialScrollIndex: initialIndex,
itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
itemPositionsListener: itemPositionsListener,
shrinkWrap: true,
padding: padding,
),
),
]),
),
);
}
testWidgets('List positioned with 0 at top and shrink wrap',
(WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
expect(tester.getBottomRight(find.text('Item 9')).dy, screenHeight);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 1 then 2 (both already on screen) with shrink wrap',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 1, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 0'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 1)
.itemLeadingEdge,
0);
expect(tester.getTopLeft(find.text('Item 1')).dy, 0);
unawaited(
itemScrollController.scrollTo(index: 2, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 1'), findsNothing);
expect(tester.getTopLeft(find.text('Item 2')).dy, 0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 11)
.itemTrailingEdge,
1);
});
testWidgets(
'Scroll to 5 (already on screen) and then back to 0 with shrink wrap',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 5, duration: scrollDuration));
await tester.pumpAndSettle();
unawaited(
itemScrollController.scrollTo(index: 0, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 9'), findsOneWidget);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 100 (not already on screen) with shrink wrap',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 99'), findsNothing);
expect(find.text('Item 100'), findsOneWidget);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('Jump to 100 with shrink wrap', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 100);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 100')).dy, 0);
expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('padding test - centered sliver at bottom with shrink wrap',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
);
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 1')),
const Offset(10, itemHeight + 10));
expect(tester.getBottomRight(find.text('Item 1')),
const Offset(screenWidth - 10, 10 + itemHeight * 2));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -100));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 499')),
const Offset(10, screenHeight - itemHeight - 10));
});
testWidgets('padding test - centered sliver not at bottom',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 200));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10, 10 + itemHeight * 2));
expect(tester.getTopLeft(find.text('Item 3')),
const Offset(10, 10 + itemHeight * 3));
});
}