From b4413be47dc5e3920a083b076dbbd9e8885b0247 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 May 2023 15:47:53 +0530 Subject: [PATCH 1/3] feat(ui): update scrollable_positioned_list with the latest changes. Signed-off-by: xsahil03x --- .../src/positioned_list.dart | 93 +- .../src/scroll_view.dart | 28 +- .../src/scrollable_positioned_list.dart | 225 ++-- .../src/viewport.dart | 47 +- .../src/wrapping.dart | 1066 +++++++++++++++++ .../message_list_view/message_list_view.dart | 12 +- ...ontal_scrollable_positioned_list_test.dart | 37 +- .../positioned_list_test.dart | 51 + ...ersed_scrollable_positioned_list_test.dart | 3 +- .../scrollable_positioned_list_test.dart | 385 +++--- ...rated_scrollable_positioned_list_test.dart | 61 +- ...ontal_scrollable_positioned_list_test.dart | 3 +- .../shrink_wrap_position_list_test.dart | 473 ++++++++ ...nk_wrap_scrollable_position_list_test.dart | 246 ++++ 14 files changed, 2333 insertions(+), 397 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart create mode 100644 packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart create mode 100644 packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index c62472ca..1ea7733e 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -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_notifier.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 /// and position reporting is based on index rather than pixel offset. @@ -35,28 +36,20 @@ class PositionedList extends StatefulWidget { this.alignment = 0, this.scrollDirection = Axis.vertical, this.reverse = false, + this.shrinkWrap = false, this.physics, this.padding, this.cacheExtent, this.semanticChildCount, - this.findChildIndexCallback, this.addSemanticIndexes = true, this.addRepaintBoundaries = true, this.addAutomaticKeepAlives = true, - this.keyboardDismissBehavior, - }) : assert((positionedIndex == 0) || (positionedIndex < itemCount), - 'positionedIndex cannot be 0 and must be smaller than itemCount'); - - /// 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; + this.findChildIndexCallback, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + }) : assert( + (positionedIndex == 0) || (positionedIndex < itemCount), + 'positionedIndex must be 0 or a value less than itemCount', + ); /// Number of items the [itemBuilder] can produce. final int itemCount; @@ -98,6 +91,15 @@ class PositionedList extends StatefulWidget { /// See [ScrollView.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. /// /// For example, determines how the scroll view continues to animate after the @@ -132,9 +134,22 @@ class PositionedList extends StatefulWidget { /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. final bool addAutomaticKeepAlives; - /// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will - /// dismiss the keyboard automatically. - final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior; + /// 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 State createState() => _PositionedListState(); @@ -175,12 +190,13 @@ class _PositionedListState extends State { anchor: widget.alignment, center: _centerKey, controller: scrollController, - keyboardDismissBehavior: widget.keyboardDismissBehavior, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: widget.cacheExtent, physics: widget.physics, + shrinkWrap: widget.shrinkWrap, semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + keyboardDismissBehavior: widget.keyboardDismissBehavior, slivers: [ if (widget.positionedIndex > 0) SliverPadding( @@ -196,9 +212,9 @@ class _PositionedListState extends State { ? widget.positionedIndex : widget.positionedIndex * 2, addSemanticIndexes: false, - findChildIndexCallback: widget.findChildIndexCallback, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -213,10 +229,10 @@ class _PositionedListState extends State { index + widget.positionedIndex * 2, ), childCount: widget.itemCount != 0 ? 1 : 0, - findChildIndexCallback: widget.findChildIndexCallback, addSemanticIndexes: false, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -234,10 +250,10 @@ class _PositionedListState extends State { childCount: widget.separatorBuilder == null ? widget.itemCount - widget.positionedIndex - 1 : 2 * (widget.itemCount - widget.positionedIndex - 1), - findChildIndexCallback: widget.findChildIndexCallback, addSemanticIndexes: false, addRepaintBoundaries: widget.addRepaintBoundaries, addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), ), @@ -319,25 +335,33 @@ class _PositionedListState extends State { if (!updateScheduled) { updateScheduled = true; SchedulerBinding.instance.addPostFrameCallback((_) { - if (registeredElements.value == null) { + final elements = registeredElements.value; + if (elements == null) { updateScheduled = false; return; } final positions = []; - RenderViewport? viewport; - for (final element in registeredElements.value!) { - final box = element.renderObject as RenderBox?; - viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; - if (viewport == null || box == null) { - break; + RenderViewportBase? viewport; + for (final element in elements) { + final box = element.renderObject! as RenderBox; + viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?; + var anchor = 0.0; + 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) { - final reveal = viewport.getOffsetToReveal(box, 0).offset; + final reveal = viewport!.getOffsetToReveal(box, 0).offset; if (!reveal.isFinite) continue; - final itemOffset = reveal - - viewport.offset.pixels + - viewport.anchor * viewport.size.height; + final itemOffset = + reveal - viewport.offset.pixels + anchor * viewport.size.height; positions.add(ItemPosition( index: key.index, itemLeadingEdge: itemOffset.round() / @@ -348,6 +372,7 @@ class _PositionedListState extends State { } else { final itemOffset = box.localToGlobal(Offset.zero, ancestor: viewport).dx; + if (!itemOffset.isFinite) continue; positions.add(ItemPosition( index: key.index, itemLeadingEdge: (widget.reverse diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart index 13a7fdd3..91151249 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -5,13 +5,14 @@ import 'package:flutter/rendering.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/wrapping.dart'; -/// {@template custom_scroll_view} -/// A version of [CustomScrollView] that does not constrict the extents +/// {@template unbounded_custom_scroll_view} +/// A version of [CustomScrollView] that allows does not constrict the extents /// to be within 0 and 1. See [CustomScrollView] for more information. /// {@endtemplate} class UnboundedCustomScrollView extends CustomScrollView { - /// {@macro custom_scroll_view} + /// {@macro unbounded_custom_scroll_view} const UnboundedCustomScrollView({ super.key, super.scrollDirection, @@ -19,19 +20,19 @@ class UnboundedCustomScrollView extends CustomScrollView { super.controller, super.primary, super.physics, - super.shrinkWrap, + bool shrinkWrap = false, super.center, double anchor = 0.0, super.cacheExtent, super.slivers, super.semanticChildCount, super.dragStartBehavior, - ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior, - }) : _anchor = anchor, - super( - keyboardDismissBehavior: keyboardDismissBehavior ?? - ScrollViewKeyboardDismissBehavior.manual, - ); + super.keyboardDismissBehavior, + }) : _shrinkWrap = shrinkWrap, + _anchor = anchor, + super(shrinkWrap: false); + + final bool _shrinkWrap; // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // we need our own version. @@ -49,11 +50,14 @@ class UnboundedCustomScrollView extends CustomScrollView { AxisDirection axisDirection, List slivers, ) { - if (shrinkWrap) { - return ShrinkWrappingViewport( + if (_shrinkWrap) { + return CustomShrinkWrappingViewport( axisDirection: axisDirection, offset: offset, slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, ); } return UnboundedViewport( diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index 5d307158..b3785154 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -37,6 +37,7 @@ class ScrollablePositionedList extends StatefulWidget { required this.itemBuilder, super.key, this.itemScrollController, + this.shrinkWrap = false, ItemPositionsListener? itemPositionsListener, this.initialScrollIndex = 0, this.initialAlignment = 0, @@ -50,7 +51,7 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - this.keyboardDismissBehavior, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, separatorBuilder = null; @@ -61,6 +62,7 @@ class ScrollablePositionedList extends StatefulWidget { required this.itemBuilder, required IndexedWidgetBuilder this.separatorBuilder, super.key, + this.shrinkWrap = false, this.itemScrollController, ItemPositionsListener? itemPositionsListener, this.initialScrollIndex = 0, @@ -75,24 +77,9 @@ class ScrollablePositionedList extends StatefulWidget { this.addRepaintBoundaries = true, this.minCacheExtent, this.findChildIndexCallback, - this.keyboardDismissBehavior, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : 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. final int itemCount; @@ -131,6 +118,15 @@ class ScrollablePositionedList extends StatefulWidget { /// See [ScrollView.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. /// /// For example, determines how the scroll view continues to animate after the @@ -171,6 +167,23 @@ class ScrollablePositionedList extends StatefulWidget { /// cache extent. 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 State createState() => _ScrollablePositionedListState(); } @@ -233,11 +246,15 @@ class ItemScrollController { Curve curve = Curves.linear, List opacityAnimationWeights = const [40, 20, 40], }) { - assert(_scrollableListState != null, '_scrollableListState cannot be null'); - assert(opacityAnimationWeights.length == 3, - 'opacityAnimationWeights.length is not equal to 3'); - assert(duration > Duration.zero, - 'duration needs to be bigger than Duration.zero'); + assert( + _scrollableListState != null, + '''ScrollController must be attached to a ScrollablePositionedList to scroll.''', + ); + assert( + opacityAnimationWeights.length == 3, + 'opacityAnimationWeights must have exactly three elements.', + ); + assert(duration > Duration.zero, 'Duration must be greater than zero.'); return _scrollableListState!._scrollTo( index: index, alignment: alignment, @@ -249,7 +266,9 @@ class ItemScrollController { void _attach(_ScrollablePositionedListState scrollableListState) { assert( - _scrollableListState == null, '_scrollableListState needs to be null'); + _scrollableListState == null, + '''ScrollController must not be attached to multiple ScrollablePositionedLists.''', + ); _scrollableListState = scrollableListState; } @@ -273,11 +292,12 @@ class _ScrollablePositionedListState extends State bool _isTransitioning = false; + AnimationController? _animationController; + @override void initState() { super.initState(); - final ItemPosition? initialPosition = - PageStorage.of(context).readState(context); + final initialPosition = PageStorage.of(context).readState(context); primary ..target = initialPosition?.index ?? widget.initialScrollIndex ..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment; @@ -301,6 +321,7 @@ class _ScrollablePositionedListState extends State .removeListener(_updatePositions); secondary.itemPositionsNotifier.itemPositions .removeListener(_updatePositions); + _animationController?.dispose(); super.dispose(); } @@ -329,84 +350,90 @@ class _ScrollablePositionedListState extends State } @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final cacheExtent = _cacheExtent(constraints); - return GestureDetector( - onPanDown: (_) => _stopScroll(canceled: true), - excludeFromSemantics: true, - child: Stack( - children: [ + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final cacheExtent = _cacheExtent(constraints); + return GestureDetector( + onPanDown: (_) => _stopScroll(canceled: true), + excludeFromSemantics: true, + child: Stack( + children: [ + PostMountCallback( + key: primary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: ReverseAnimation(opacity), + child: NotificationListener( + 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( - key: primary.key, + key: secondary.key, callback: startAnimationCallback, child: FadeTransition( - opacity: ReverseAnimation(opacity), + opacity: opacity, child: NotificationListener( - onNotification: (_) => _isTransitioning, + onNotification: (_) => false, child: PositionedList( - keyboardDismissBehavior: widget.keyboardDismissBehavior, itemBuilder: widget.itemBuilder, separatorBuilder: widget.separatorBuilder, itemCount: widget.itemCount, - positionedIndex: primary.target, - controller: primary.scrollController, - itemPositionsNotifier: primary.itemPositionsNotifier, + itemPositionsNotifier: secondary.itemPositionsNotifier, + positionedIndex: secondary.target, + controller: secondary.scrollController, scrollDirection: widget.scrollDirection, reverse: widget.reverse, cacheExtent: cacheExtent, - alignment: primary.alignment, + alignment: secondary.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( - key: secondary.key, - callback: startAnimationCallback, - child: FadeTransition( - opacity: opacity, - child: NotificationListener( - 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( - constraints.maxHeight * _screenScrollCount, + (widget.scrollDirection == Axis.vertical + ? constraints.maxHeight + : constraints.maxWidth) * + _screenScrollCount, widget.minCacheExtent ?? 0, ); @@ -434,16 +461,19 @@ class _ScrollablePositionedListState extends State index = widget.itemCount - 1; } if (_isTransitioning) { + final scrollCompleter = Completer(); _stopScroll(canceled: true); - SchedulerBinding.instance.addPostFrameCallback((_) { - _startScroll( + SchedulerBinding.instance.addPostFrameCallback((_) async { + await _startScroll( index: index, alignment: alignment, duration: duration, curve: curve, opacityAnimationWeights: opacityAnimationWeights, ); + scrollCompleter.complete(); }); + await scrollCompleter.future; } else { await _startScroll( index: index, @@ -486,10 +516,11 @@ class _ScrollablePositionedListState extends State startAnimationCallback = () { SchedulerBinding.instance.addPostFrameCallback((_) { startAnimationCallback = () {}; - - opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( - AnimationController(vsync: this, duration: duration)..forward(), - ); + _animationController?.dispose(); + _animationController = + AnimationController(vsync: this, duration: duration)..forward(); + opacity.parent = _opacityAnimation(opacityAnimationWeights) + .animate(_animationController!); secondary.scrollController.jumpTo(-direction * (_screenScrollCount * primary.scrollController.position.viewportDimension - @@ -532,17 +563,19 @@ class _ScrollablePositionedListState extends State } } - setState(() { - if (opacity.value >= 0.5) { - // Secondary [ListView] is more visible than the primary; make it the - // new primary. - final temp = primary; - primary = secondary; - secondary = temp; - } - _isTransitioning = false; - opacity.parent = const AlwaysStoppedAnimation(0); - }); + if (mounted) { + setState(() { + if (opacity.value >= 0.5) { + // Secondary [ListView] is more visible than the primary; make it the + // new primary. + final temp = primary; + primary = secondary; + secondary = temp; + } + _isTransitioning = false; + opacity.parent = const AlwaysStoppedAnimation(0); + }); + } } Animatable _opacityAnimation(List opacityAnimationWeights) { diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart index 7d2d6b9f..aac9acc9 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// ignore_for_file: lines_longer_than_80_chars - import 'dart:math' as math; import 'package:flutter/rendering.dart'; @@ -15,7 +13,7 @@ import 'package:flutter/widgets.dart'; /// Version of [Viewport] with some modifications to how extents are /// computed to allow scroll extents outside 0 to 1. See [Viewport] /// for more information. -/// description +/// {@endtemplate} class UnboundedViewport extends Viewport { /// {@macro unbounded_viewport} UnboundedViewport({ @@ -37,15 +35,16 @@ class UnboundedViewport extends Viewport { double get anchor => _anchor; @override - RenderViewport createRenderObject(BuildContext context) => - UnboundedRenderViewport( - axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection), - anchor: anchor, - offset: offset, - cacheExtent: cacheExtent, - ); + RenderViewport createRenderObject(BuildContext context) { + return UnboundedRenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + ); + } } /// A render object that is bigger on the inside. @@ -137,14 +136,20 @@ class UnboundedRenderViewport extends RenderViewport { @override void performLayout() { 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; _maxScrollExtent = 0.0; _hasVisualOverflow = false; offset.applyContentDimensions(0, 0); 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 crossAxisExtent; @@ -186,7 +191,7 @@ class UnboundedRenderViewport extends RenderViewport { } while (count < _maxLayoutCycles); assert(() { if (count >= _maxLayoutCycles) { - assert(count != 1, 'count not equal to 1'); + assert(count != 1); throw FlutterError( 'A RenderViewport exceeded its maximum number of layout cycles.\n' 'RenderViewport render objects, during layout, can retry if either their ' @@ -207,7 +212,7 @@ class UnboundedRenderViewport extends RenderViewport { ); } return true; - }(), 'count needs to be bigger than _maxLayoutCycles'); + }()); } double _attemptLayout( @@ -215,11 +220,11 @@ class UnboundedRenderViewport extends RenderViewport { double crossAxisExtent, double correctedOffset, ) { - assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); - assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); - assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); - assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); - assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); + assert(!mainAxisExtent.isNaN, 'The main axis extent cannot be NaN.'); + assert(mainAxisExtent >= 0.0, 'The main axis extent cannot be negative.'); + assert(crossAxisExtent.isFinite, 'The cross axis extent must be finite.'); + assert(crossAxisExtent >= 0.0, 'The cross axis extent cannot be negative.'); + assert(correctedOffset.isFinite, 'The corrected offset must be finite.'); _minScrollExtent = 0.0; _maxScrollExtent = 0.0; _hasVisualOverflow = false; diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart new file mode 100644 index 00000000..c3a8129c --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart @@ -0,0 +1,1066 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that is bigger on the inside and shrink wraps its children in the +/// main axis. +/// +/// [ShrinkWrappingViewport] displays a subset of its children according to its +/// own dimensions and the given [offset]. As the offset varies, different +/// children are visible through the viewport. +/// +/// [ShrinkWrappingViewport] differs from [Viewport] in that [Viewport] expands +/// to fill the main axis whereas [ShrinkWrappingViewport] sizes itself to match +/// its children in the main axis. This shrink wrapping behavior is expensive +/// because the children, and hence the viewport, could potentially change size +/// whenever the [offset] changes (e.g., because of a collapsing header). +/// +/// [ShrinkWrappingViewport] cannot contain box children directly. Instead, use +/// a [SliverList], [SliverFixedExtentList], [SliverGrid], or a +/// [SliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine +/// [Scrollable] and [ShrinkWrappingViewport] into widgets that are easier to +/// use. +/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a +/// sliver context (the opposite of this widget). +/// * [Viewport], a viewport that does not shrink-wrap its contents. +class CustomShrinkWrappingViewport extends CustomViewport { + /// Creates a widget that is bigger on the inside and shrink wraps its + /// children in the main axis. + /// + /// The viewport listens to the [offset], which means you do not need to + /// rebuild this widget when the [offset] changes. + /// + /// The [offset] argument must not be null. + CustomShrinkWrappingViewport({ + super.key, + super.axisDirection, + super.crossAxisDirection, + double anchor = 0.0, + required super.offset, + List? children, + super.center, + super.cacheExtent, + super.slivers, + }) : _anchor = anchor; + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) { + return CustomRenderShrinkWrappingViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + offset: offset, + anchor: anchor, + cacheExtent: cacheExtent, + ); + } + + @override + void updateRenderObject( + BuildContext context, + CustomRenderShrinkWrappingViewport renderObject, + ) { + renderObject + ..axisDirection = axisDirection + ..crossAxisDirection = crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection) + ..anchor = anchor + ..offset = offset + ..cacheExtent = cacheExtent + ..cacheExtentStyle = cacheExtentStyle + ..clipBehavior = clipBehavior; + } +} + +/// A render object that is bigger on the inside and shrink wraps its children +/// in the main axis. +/// +/// [RenderShrinkWrappingViewport] displays a subset of its children according +/// to its own dimensions and the given [offset]. As the offset varies, different +/// children are visible through the viewport. +/// +/// [RenderShrinkWrappingViewport] differs from [RenderViewport] in that +/// [RenderViewport] expands to fill the main axis whereas +/// [RenderShrinkWrappingViewport] sizes itself to match its children in the +/// main axis. This shrink wrapping behavior is expensive because the children, +/// and hence the viewport, could potentially change size whenever the [offset] +/// changes (e.g., because of a collapsing header). +/// +/// [RenderShrinkWrappingViewport] cannot contain [RenderBox] children directly. +/// Instead, use a [RenderSliverList], [RenderSliverFixedExtentList], +/// [RenderSliverGrid], or a [RenderSliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [RenderViewport], a viewport that does not shrink-wrap its contents. +/// * [RenderSliver], which explains more about the Sliver protocol. +/// * [RenderBox], which explains more about the Box protocol. +/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be +/// placed inside a [RenderSliver] (the opposite of this class). +class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { + /// Creates a viewport (for [RenderSliver] objects) that shrink-wraps its + /// contents. + /// + /// The [offset] must be specified. For testing purposes, consider passing a + /// [ViewportOffset.zero] or [ViewportOffset.fixed]. + CustomRenderShrinkWrappingViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + double anchor = 0.0, + super.children, + super.center, + super.cacheExtent, + }) : _anchor = anchor; + + double _anchor; + + @override + double get anchor => _anchor; + + @override + bool get sizedByParent => false; + + double lastMainAxisExtent = -1; + + @override + set anchor(double value) { + if (value == _anchor) return; + _anchor = value; + markNeedsLayout(); + } + + late double _shrinkWrapExtent; + + /// This value is set during layout based on the [CacheExtentStyle]. + /// + /// When the style is [CacheExtentStyle.viewport], it is the main axis extent + /// of the viewport multiplied by the requested cache extent, which is still + /// expressed in pixels. + double? _calculatedCacheExtent; + + /// While List in a wrapping container, eg. ListView,the mainAxisExtent will + /// be infinite. This time need to change mainAxisExtent to this value. + final double _maxMainAxisExtent = double.maxFinite; + + @override + void performLayout() { + if (center == null) { + assert(firstChild == null, 'center must be null if children are present'); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0, 0); + return; + } + + assert(center!.parent == this, 'center must be a child of the viewport'); + + final constraints = this.constraints; + if (firstChild == null) { + switch (axis) { + case Axis.vertical: + assert( + constraints.hasBoundedWidth, + 'Vertical viewport was given ' + 'unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount ' + 'of horizontal space in which to expand.', + ); + size = Size(constraints.maxWidth, constraints.minHeight); + break; + case Axis.horizontal: + assert( + constraints.hasBoundedHeight, + 'Horizontal viewport was given ' + 'unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount ' + 'of vertical space in which to expand.', + ); + size = Size(constraints.minWidth, constraints.maxHeight); + break; + } + offset.applyViewportDimension(0); + _maxScrollExtent = 0.0; + _shrinkWrapExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0, 0); + return; + } + + double mainAxisExtent; + final double crossAxisExtent; + switch (axis) { + case Axis.vertical: + assert( + constraints.hasBoundedWidth, + 'Vertical viewport was given ' + 'unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount ' + 'of horizontal space in which to expand.', + ); + mainAxisExtent = constraints.maxHeight; + crossAxisExtent = constraints.maxWidth; + break; + case Axis.horizontal: + assert( + constraints.hasBoundedHeight, + 'Horizontal viewport was given ' + 'unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount ' + 'of vertical space in which to expand.', + ); + mainAxisExtent = constraints.maxWidth; + crossAxisExtent = constraints.maxHeight; + break; + } + + if (mainAxisExtent.isInfinite) { + mainAxisExtent = _maxMainAxisExtent; + } + + final centerOffsetAdjustment = center!.centerOffsetAdjustment; + + double correction; + double effectiveExtent; + do { + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + switch (axis) { + case Axis.vertical: + effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent); + break; + case Axis.horizontal: + effectiveExtent = constraints.constrainWidth(_shrinkWrapExtent); + break; + } + // *** Difference from [RenderViewport]. + final top = _minScrollExtent + mainAxisExtent * anchor; + final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); + + final maxScrollOffset = math.max(math.min(0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + + final didAcceptViewportDimension = + offset.applyViewportDimension(effectiveExtent); + final didAcceptContentDimension = + offset.applyContentDimensions(minScrollOffset, maxScrollOffset); + if (didAcceptViewportDimension && didAcceptContentDimension) { + break; + } + } + } while (true); + switch (axis) { + case Axis.vertical: + size = + constraints.constrainDimensions(crossAxisExtent, effectiveExtent); + break; + case Axis.horizontal: + size = + constraints.constrainDimensions(effectiveExtent, crossAxisExtent); + break; + } + } + + double _attemptLayout( + double mainAxisExtent, + double crossAxisExtent, + double correctedOffset, + ) { + assert(!mainAxisExtent.isNaN, 'The maxExtent of $this has not been set.'); + assert(mainAxisExtent >= 0.0, 'The maxExtent of $this is negative.'); + assert( + crossAxisExtent.isFinite, + 'The crossAxisExtent of $this is not finite.', + ); + assert(crossAxisExtent >= 0.0, 'The crossAxisExtent of $this is negative.'); + assert( + correctedOffset.isFinite, + 'The correctedOffset of $this is not finite.', + ); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + _shrinkWrapExtent = 0.0; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + final centerOffset = mainAxisExtent * anchor - correctedOffset; + final reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: (mainAxisExtent - centerOffset) + .clamp(-_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) return -result; + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, + layoutOffset: centerOffset >= mainAxisExtent + ? centerOffset + : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + _shrinkWrapExtent += childLayoutGeometry.maxPaintExtent; + growSize = _shrinkWrapExtent; + } + + @override + String labelForChild(int index) => 'child $index'; +} + +/// A widget that is bigger on the inside. +/// +/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a +/// subset of its children according to its own dimensions and the given +/// [offset]. As the offset varies, different children are visible through +/// the viewport. +/// +/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center] +/// sliver, which is placed at the zero scroll offset. The center widget is +/// displayed in the viewport according to the [anchor] property. +/// +/// Slivers that are earlier in the child list than [center] are displayed in +/// reverse order in the reverse [axisDirection] starting from the [center]. For +/// example, if the [axisDirection] is [AxisDirection.down], the first sliver +/// before [center] is placed above the [center]. The slivers that are later in +/// the child list than [center] are placed in order in the [axisDirection]. For +/// example, in the preceding scenario, the first sliver after [center] is +/// placed below the [center]. +/// +/// [Viewport] cannot contain box children directly. Instead, use a +/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a +/// [SliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine +/// [Scrollable] and [Viewport] into widgets that are easier to use. +/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a +/// sliver context (the opposite of this widget). +/// * [ShrinkWrappingViewport], a variant of [Viewport] that shrink-wraps its +/// contents along the main axis. +abstract class CustomViewport extends MultiChildRenderObjectWidget { + /// Creates a widget that is bigger on the inside. + /// + /// The viewport listens to the [offset], which means you do not need to + /// rebuild this widget when the [offset] changes. + /// + /// The [offset] argument must not be null. + /// + /// The [cacheExtent] must be specified if the [cacheExtentStyle] is + /// not [CacheExtentStyle.pixel]. + CustomViewport({ + super.key, + this.axisDirection = AxisDirection.down, + this.crossAxisDirection, + this.anchor = 0.0, + required this.offset, + this.center, + this.cacheExtent, + this.cacheExtentStyle = CacheExtentStyle.pixel, + this.clipBehavior = Clip.hardEdge, + List slivers = const [], + }) : assert( + center == null || + slivers.where((Widget child) => child.key == center).length == 1, + 'There should be at most one child with the same key as the center child: $center', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using cacheExtentStyle.viewport', + ), + super(children: slivers); + + /// The direction in which the [offset]'s [ViewportOffset.pixels] increases. + /// + /// For example, if the [axisDirection] is [AxisDirection.down], a scroll + /// offset of zero is at the top of the viewport and increases towards the + /// bottom of the viewport. + final AxisDirection axisDirection; + + /// The direction in which child should be laid out in the cross axis. + /// + /// If the [axisDirection] is [AxisDirection.down] or [AxisDirection.up], this + /// property defaults to [AxisDirection.left] if the ambient [Directionality] + /// is [TextDirection.rtl] and [AxisDirection.right] if the ambient + /// [Directionality] is [TextDirection.ltr]. + /// + /// If the [axisDirection] is [AxisDirection.left] or [AxisDirection.right], + /// this property defaults to [AxisDirection.down]. + final AxisDirection? crossAxisDirection; + + /// The relative position of the zero scroll offset. + /// + /// For example, if [anchor] is 0.5 and the [axisDirection] is + /// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is + /// vertically centered within the viewport. If the [anchor] is 1.0, and the + /// [axisDirection] is [AxisDirection.right], then the zero scroll offset is + /// on the left edge of the viewport. + final double anchor; + + /// Which part of the content inside the viewport should be visible. + /// + /// The [ViewportOffset.pixels] value determines the scroll offset that the + /// viewport uses to select which part of its content to display. As the user + /// scrolls the viewport, this value changes, which changes the content that + /// is displayed. + /// + /// Typically a [ScrollPosition]. + final ViewportOffset offset; + + /// The first child in the [GrowthDirection.forward] growth direction. + /// + /// Children after [center] will be placed in the [axisDirection] relative to + /// the [center]. Children before [center] will be placed in the opposite of + /// the [axisDirection] relative to the [center]. + /// + /// The [center] must be the key of a child of the viewport. + final Key? center; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + /// + /// See also: + /// + /// * [cacheExtentStyle], which controls the units of the [cacheExtent]. + final double? cacheExtent; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtentStyle} + final CacheExtentStyle cacheExtentStyle; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// Given a [BuildContext] and an [AxisDirection], determine the correct cross + /// axis direction. + /// + /// This depends on the [Directionality] if the `axisDirection` is vertical; + /// otherwise, the default cross axis direction is downwards. + static AxisDirection getDefaultCrossAxisDirection( + BuildContext context, + AxisDirection axisDirection, + ) { + switch (axisDirection) { + case AxisDirection.up: + assert(debugCheckHasDirectionality( + context, + why: + "to determine the cross-axis direction when the viewport has an 'up' axisDirection", + alternative: + "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + )); + return textDirectionToAxisDirection(Directionality.of(context)); + case AxisDirection.right: + return AxisDirection.down; + case AxisDirection.down: + assert(debugCheckHasDirectionality( + context, + why: + "to determine the cross-axis direction when the viewport has a 'down' axisDirection", + alternative: + "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + )); + return textDirectionToAxisDirection(Directionality.of(context)); + case AxisDirection.left: + return AxisDirection.down; + } + } + + @override + CustomRenderViewport createRenderObject(BuildContext context); + + @override + _ViewportElement createElement() => _ViewportElement(this); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('axisDirection', axisDirection)) + ..add(EnumProperty( + 'crossAxisDirection', + crossAxisDirection, + defaultValue: null, + )) + ..add(DoubleProperty('anchor', anchor)) + ..add(DiagnosticsProperty('offset', offset)); + if (center != null) { + properties.add(DiagnosticsProperty('center', center)); + } else if (children.isNotEmpty && children.first.key != null) { + properties.add(DiagnosticsProperty( + 'center', + children.first.key, + tooltip: 'implicit', + )); + } + properties + ..add(DiagnosticsProperty('cacheExtent', cacheExtent)) + ..add(DiagnosticsProperty( + 'cacheExtentStyle', + cacheExtentStyle, + )); + } +} + +class _ViewportElement extends MultiChildRenderObjectElement { + /// Creates an element that uses the given widget as its configuration. + _ViewportElement(CustomViewport super.widget); + + @override + CustomViewport get widget => super.widget as CustomViewport; + + @override + CustomRenderViewport get renderObject => + super.renderObject as CustomRenderViewport; + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + _updateCenter(); + } + + @override + void update(MultiChildRenderObjectWidget newWidget) { + super.update(newWidget); + _updateCenter(); + } + + void _updateCenter() { + if (widget.center != null) { + renderObject.center = children + .singleWhere((Element element) => element.widget.key == widget.center) + .renderObject as RenderSliver?; + } else if (children.isNotEmpty) { + renderObject.center = children.first.renderObject as RenderSliver?; + } else { + renderObject.center = null; + } + } + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + children.where((Element e) { + final renderSliver = e.renderObject! as RenderSliver; + return renderSliver.geometry!.visible; + }).forEach(visitor); + } +} + +class CustomSliverPhysicalContainerParentData + extends SliverPhysicalContainerParentData { + /// The position of the child relative to the zero scroll offset. + /// + /// The number of pixels from from the zero scroll offset of the parent sliver + /// (the line at which its [SliverConstraints.scrollOffset] is zero) to the + /// side of the child closest to that offset. A [layoutOffset] can be null + /// when it cannot be determined. The value will be set after layout. + /// + /// In a typical list, this does not change as the parent is scrolled. + /// + /// Defaults to null. + double? layoutOffset; + + GrowthDirection? growthDirection; +} + +/// A render object that is bigger on the inside. +/// +/// [RenderViewport] is the visual workhorse of the scrolling machinery. It +/// displays a subset of its children according to its own dimensions and the +/// given [offset]. As the offset varies, different children are visible through +/// the viewport. +/// +/// [RenderViewport] hosts a bidirectional list of slivers, anchored on a +/// [center] sliver, which is placed at the zero scroll offset. The center +/// widget is displayed in the viewport according to the [anchor] property. +/// +/// Slivers that are earlier in the child list than [center] are displayed in +/// reverse order in the reverse [axisDirection] starting from the [center]. For +/// example, if the [axisDirection] is [AxisDirection.down], the first sliver +/// before [center] is placed above the [center]. The slivers that are later in +/// the child list than [center] are placed in order in the [axisDirection]. For +/// example, in the preceding scenario, the first sliver after [center] is +/// placed below the [center]. +/// +/// [RenderViewport] cannot contain [RenderBox] children directly. Instead, use +/// a [RenderSliverList], [RenderSliverFixedExtentList], [RenderSliverGrid], or +/// a [RenderSliverToBoxAdapter], for example. +/// +/// See also: +/// +/// * [RenderSliver], which explains more about the Sliver protocol. +/// * [RenderBox], which explains more about the Box protocol. +/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be +/// placed inside a [RenderSliver] (the opposite of this class). +/// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that +/// shrink-wraps its contents along the main axis. +abstract class CustomRenderViewport + extends RenderViewportBase { + /// Creates a viewport for [RenderSliver] objects. + /// + /// If the [center] is not specified, then the first child in the `children` + /// list, if any, is used. + /// + /// The [offset] must be specified. For testing purposes, consider passing a + /// [ViewportOffset.zero] or [ViewportOffset.fixed]. + CustomRenderViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + double anchor = 0.0, + List? children, + RenderSliver? center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + }) : assert( + anchor >= 0.0 && anchor <= 1.0, + 'Anchor must be between 0.0 and 1.0.', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using CacheExtentStyle.viewport.', + ), + _center = center { + addAll(children); + if (center == null && firstChild != null) _center = firstChild; + } + + /// If a [RenderAbstractViewport] overrides + /// [RenderObject.describeSemanticsConfiguration] to add the [SemanticsTag] + /// [useTwoPaneSemantics] to its [SemanticsConfiguration], two semantics nodes + /// will be used to represent the viewport with its associated scrolling + /// actions in the semantics tree. + /// + /// Two semantics nodes (an inner and an outer node) are necessary to exclude + /// certain child nodes (via the [excludeFromScrolling] tag) from the + /// scrollable area for semantic purposes: The [SemanticsNode]s of children + /// that should be excluded from scrolling will be attached to the outer node. + /// The semantic scrolling actions and the [SemanticsNode]s of scrollable + /// children will be attached to the inner node, which itself is a child of + /// the outer node. + /// + /// See also: + /// + /// * [RenderViewportBase.describeSemanticsConfiguration], which adds this + /// tag to its [SemanticsConfiguration]. + static const SemanticsTag useTwoPaneSemantics = + SemanticsTag('RenderViewport.twoPane'); + + /// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is + /// tagged with [excludeFromScrolling] it will not be part of the scrolling + /// area for semantic purposes. + /// + /// This behavior is only active if the [RenderAbstractViewport] + /// tagged its [SemanticsConfiguration] with [useTwoPaneSemantics]. + /// Otherwise, the [excludeFromScrolling] tag is ignored. + /// + /// As an example, a [RenderSliver] that stays on the screen within a + /// [Scrollable] even though the user has scrolled past it (e.g. a pinned app + /// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate + /// that it should no longer be considered for semantic actions related to + /// scrolling. + static const SemanticsTag excludeFromScrolling = + SemanticsTag('RenderViewport.excludeFromScrolling'); + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! CustomSliverPhysicalContainerParentData) { + child.parentData = CustomSliverPhysicalContainerParentData(); + } + } + + /// The relative position of the zero scroll offset. + /// + /// For example, if [anchor] is 0.5 and the [axisDirection] is + /// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is + /// vertically centered within the viewport. If the [anchor] is 1.0, and the + /// [axisDirection] is [AxisDirection.right], then the zero scroll offset is + /// on the left edge of the viewport. + double get anchor; + + set anchor(double value); + + /// The first child in the [GrowthDirection.forward] growth direction. + /// + /// This child that will be at the position defined by [anchor] when the + /// [ViewportOffset.pixels] of [offset] is `0`. + /// + /// Children after [center] will be placed in the [axisDirection] relative to + /// the [center]. Children before [center] will be placed in the opposite of + /// the [axisDirection] relative to the [center]. + /// + /// The [center] must be a child of the viewport. + RenderSliver? get center => _center; + RenderSliver? _center; + + set center(RenderSliver? value) { + if (value == _center) return; + _center = value; + markNeedsLayout(); + } + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + assert(() { + if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) { + switch (axis) { + case Axis.vertical: + if (!constraints.hasBoundedHeight) { + throw FlutterError.fromParts([ + ErrorSummary('Vertical viewport was given unbounded height.'), + ErrorDescription( + 'Viewports expand in the scrolling direction to fill their container. ' + 'In this case, a vertical viewport was given an unlimited amount of ' + 'vertical space in which to expand. This situation typically happens ' + 'when a scrollable widget is nested inside another scrollable widget.', + ), + ErrorHint( + 'If this widget is always nested in a scrollable widget there ' + 'is no need to use a viewport because there will always be enough ' + 'vertical space for the children. In this case, consider using a ' + 'Column instead. Otherwise, consider using the "shrinkWrap" property ' + '(or a ShrinkWrappingViewport) to size the height of the viewport ' + 'to the sum of the heights of its children.', + ), + ]); + } + if (!constraints.hasBoundedWidth) { + throw FlutterError( + 'Vertical viewport was given unbounded width.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a vertical viewport was given an unlimited amount of ' + 'horizontal space in which to expand.', + ); + } + break; + case Axis.horizontal: + if (!constraints.hasBoundedWidth) { + throw FlutterError.fromParts([ + ErrorSummary( + 'Horizontal viewport was given unbounded width.', + ), + ErrorDescription( + 'Viewports expand in the scrolling direction to fill their container. ' + 'In this case, a horizontal viewport was given an unlimited amount of ' + 'horizontal space in which to expand. This situation typically happens ' + 'when a scrollable widget is nested inside another scrollable widget.', + ), + ErrorHint( + 'If this widget is always nested in a scrollable widget there ' + 'is no need to use a viewport because there will always be enough ' + 'horizontal space for the children. In this case, consider using a ' + 'Row instead. Otherwise, consider using the "shrinkWrap" property ' + '(or a ShrinkWrappingViewport) to size the width of the viewport ' + 'to the sum of the widths of its children.', + ), + ]); + } + if (!constraints.hasBoundedHeight) { + throw FlutterError( + 'Horizontal viewport was given unbounded height.\n' + 'Viewports expand in the cross axis to fill their container and ' + 'constrain their children to match their extent in the cross axis. ' + 'In this case, a horizontal viewport was given an unlimited amount of ' + 'vertical space in which to expand.', + ); + } + break; + } + } + return true; + }()); + return constraints.biggest; + } + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + double growSize = 0; + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + } + + @override + void updateChildLayoutOffset( + RenderSliver child, + double layoutOffset, + GrowthDirection growthDirection, + ) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + childParentData + ..layoutOffset = layoutOffset + ..growthDirection = growthDirection; + } + + @override + Offset paintOffsetOf(RenderSliver child) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + return computeAbsolutePaintOffset( + child, + childParentData.layoutOffset!, + childParentData.growthDirection!, + ); + } + + @override + double scrollOffsetOf(RenderSliver child, double scrollOffsetWithinChild) { + assert( + child.parent == this, + 'The "child" argument must be a child of this RenderViewport.', + ); + final growthDirection = child.constraints.growthDirection; + switch (growthDirection) { + case GrowthDirection.forward: + var scrollOffsetToChild = 0.0; + var current = center; + while (current != child) { + scrollOffsetToChild += current!.geometry!.scrollExtent; + current = childAfter(current); + } + return scrollOffsetToChild + scrollOffsetWithinChild; + case GrowthDirection.reverse: + var scrollOffsetToChild = 0.0; + var current = childBefore(center!); + while (current != child) { + scrollOffsetToChild -= current!.geometry!.scrollExtent; + current = childBefore(current); + } + return scrollOffsetToChild - scrollOffsetWithinChild; + } + } + + @override + double maxScrollObstructionExtentBefore(RenderSliver child) { + assert( + child.parent == this, + 'The "child" argument must be a child of this RenderViewport.', + ); + final growthDirection = child.constraints.growthDirection; + switch (growthDirection) { + case GrowthDirection.forward: + var pinnedExtent = 0.0; + var current = center; + while (current != child) { + pinnedExtent += current!.geometry!.maxScrollObstructionExtent; + current = childAfter(current); + } + return pinnedExtent; + case GrowthDirection.reverse: + var pinnedExtent = 0.0; + var current = childBefore(center!); + while (current != child) { + pinnedExtent += current!.geometry!.maxScrollObstructionExtent; + current = childBefore(current); + } + return pinnedExtent; + } + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + final offset = paintOffsetOf(child as RenderSliver); + transform.translate(offset.dx, offset.dy); + } + + @override + double computeChildMainAxisPosition( + RenderSliver child, + double parentMainAxisPosition, + ) { + final childParentData = + child.parentData! as CustomSliverPhysicalContainerParentData; + switch (applyGrowthDirectionToAxisDirection( + child.constraints.axisDirection, + child.constraints.growthDirection, + )) { + case AxisDirection.down: + case AxisDirection.right: + return parentMainAxisPosition - childParentData.layoutOffset!; + case AxisDirection.up: + return (size.height - parentMainAxisPosition) - + childParentData.layoutOffset!; + case AxisDirection.left: + return (size.width - parentMainAxisPosition) - + childParentData.layoutOffset!; + } + } + + @override + int get indexOfFirstChild { + assert(center != null, 'RenderViewport does not have any children.'); + assert( + center!.parent == this, + 'center is not a child of this RenderViewport', + ); + assert( + firstChild != null, + 'center is the only child of this RenderViewport', + ); + var count = 0; + var child = center; + while (child != firstChild) { + count -= 1; + child = childBefore(child!); + } + return count; + } + + @override + String labelForChild(int index) { + if (index == 0) return 'center child'; + return 'child $index'; + } + + @override + Iterable get childrenInPaintOrder sync* { + if (firstChild == null) return; + var child = firstChild; + while (child != center) { + yield child!; + child = childAfter(child); + } + child = lastChild; + while (true) { + yield child!; + if (child == center) return; + child = childBefore(child); + } + } + + @override + Iterable get childrenInHitTestOrder sync* { + if (firstChild == null) return; + var child = center; + while (child != null) { + yield child; + child = childAfter(child); + } + child = childBefore(center!); + while (child != null) { + yield child; + child = childBefore(child); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('anchor', anchor)); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index b6aa25b5..c66c88f8 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -71,7 +71,7 @@ enum SpacingType { /// A [StreamChannel] ancestor widget is required in order to provide the /// 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]. /// Modify it to change the widget's appearance. @@ -88,8 +88,10 @@ class StreamMessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.dateDividerBuilder, - this.scrollPhysics = - const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading + // we need to use ClampingScrollPhysics to avoid the list view to bounce + // 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.initialAlignment, this.scrollController, @@ -555,6 +557,10 @@ class _StreamMessageListViewState extends State { if (valueKey != null) { final index = messagesIndex[valueKey.value]; 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; } } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart index fb5c9fc2..73568c19 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -2,13 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - 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 screenHeight = 100.0; const screenWidth = 400.0; const itemWidth = screenWidth / 10.0; 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 { final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); @@ -172,7 +176,7 @@ void main() { await tester.pumpAndSettle(); 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( itemPositionsListener.itemPositions.value @@ -196,6 +200,31 @@ void main() { 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(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(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', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart index 9888249c..5dadbda0 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart @@ -360,4 +360,55 @@ void main() { .itemTrailingEdge, 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(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); + }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart index a5af56a8..cb8d07e3 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - 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; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart index ef715743..6419e492 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -2,12 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.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/src/scroll_view.dart'; @@ -48,8 +48,7 @@ void main() { itemCount: itemCount, itemScrollController: itemScrollController, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - '''index needs to be bigger or equal to 0 and smallert than itemCount -1'''); + assert(index >= 0 && index <= itemCount - 1); return SizedBox( height: variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, @@ -71,6 +70,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 { final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); @@ -394,11 +398,7 @@ void main() { itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; unawaited( @@ -407,11 +407,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -456,10 +452,6 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - final fadeTransitionFinder = find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)); - unawaited( itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); @@ -533,26 +525,14 @@ void main() { await tester.pump(); await tester.pump(); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(0, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0, 0.01), + ); await tester.pump(scrollDuration + scrollDurationTolerance); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(1, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); expect(find.text('Item 0'), findsOneWidget); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); @@ -610,15 +590,9 @@ void main() { expect(tester.getTopLeft(find.text('Item 10')).dy, 0); expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); expect( - tester - .widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last) - .opacity - .value, - closeTo(0.5, 0.01)); + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0.5, 0.01), + ); await tester.pumpAndSettle(); }); @@ -899,11 +873,7 @@ void main() { await tester.pump(); expect(tester.getTopLeft(find.text('Item 9')).dy, 0); - final fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + final fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity.value, 1.0); await tester.pumpAndSettle(); @@ -923,21 +893,12 @@ void main() { await tester.pump(); expect(tester.getTopLeft(find.text('Item 10')).dy, 0); - final fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + final fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity.value, 1.0); 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 { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); @@ -1022,14 +983,13 @@ void main() { itemScrollController.scrollTo(index: 0, duration: scrollDuration)); 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.pump(); - expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); - final fadeTransition = tester.widget(fadeTransitionFinder); - expect(fadeTransition.opacity.value, 1.0); + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(fadeTransitionFinder, findsNWidgets(1)); await tester.pumpAndSettle(); }); @@ -1098,6 +1058,34 @@ void main() { 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', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1145,7 +1133,7 @@ void main() { }, skip: true); 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 { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); @@ -1664,7 +1652,7 @@ void main() { }); 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 { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); @@ -1751,21 +1739,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount), - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount - 1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1795,19 +1783,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: min(100, itemCount - 1), - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount -1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1834,19 +1822,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.builder( - initialScrollIndex: itemCount - 1, - itemCount: itemCount, - itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1, - 'index not bigger than 0 and smaller than itemCount -1'); - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: itemCount - 1, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -1878,11 +1866,7 @@ void main() { minCacheExtent: 10, ); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; unawaited( @@ -1891,11 +1875,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -1914,11 +1894,9 @@ void main() { minCacheExtent: itemHeight * 200, ); - var fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + var fadeTransition = tester.widget( + fadeTransitionFinder, + ); final initialOpacity = fadeTransition.opacity; unawaited( @@ -1927,11 +1905,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration ~/ 2); - fadeTransition = tester.widget(find - .descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(FadeTransition)) - .last); + fadeTransition = tester.widget(fadeTransitionFinder); expect(fadeTransition.opacity, initialOpacity); await tester.pumpAndSettle(); @@ -1965,17 +1939,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 200, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2054,15 +2032,19 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => ScrollablePositionedList.builder( - key: key, - itemCount: 10, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, key, child) { + return ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -2084,17 +2066,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: key, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2124,18 +2110,22 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: containerKey, - builder: (context, key, child) => Container( - key: key, - child: ScrollablePositionedList.builder( - key: scrollKey, - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + builder: (context, key, child) { + return Container( + key: key, + child: ScrollablePositionedList.builder( + key: scrollKey, + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, ), - ), - ), + ); + }, ), ), ); @@ -2166,15 +2156,18 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ); @@ -2215,29 +2208,35 @@ void main() { Expanded( child: ValueListenableBuilder( valueListenable: topItemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), Expanded( child: ValueListenableBuilder( valueListenable: bottomItemScrollControllerListenable, - builder: (context, itemScrollController, child) => - ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - ), + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, ), ), ], diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index df1035e2..c7439d5c 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - 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'; import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart'; @@ -497,20 +496,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.separated( - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, ), ), ); @@ -538,20 +538,21 @@ void main() { MaterialApp( home: ValueListenableBuilder( valueListenable: itemCount, - builder: (context, itemCount, child) => - ScrollablePositionedList.separated( - itemCount: itemCount, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), - ), - separatorBuilder: (context, index) => SizedBox( - height: separatorHeight, - child: Text('Separator $index'), - ), - ), + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ); + }, ), ), ); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart index c26ee43f..f92c9903 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -2,10 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - 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; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart new file mode 100644 index 00000000..c2839717 --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart @@ -0,0 +1,473 @@ +// 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 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); + }); +} diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart new file mode 100644 index 00000000..c9a0b06c --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart @@ -0,0 +1,246 @@ +// 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 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)); + }); +} From fbf9191795cd80737cd169e15fdc254e1911946a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 May 2023 15:53:30 +0530 Subject: [PATCH 2/3] feat(ui): add support for `StreamMessageListView.shrinkWrap`. Signed-off-by: xsahil03x --- packages/stream_chat_flutter/CHANGELOG.md | 2 ++ .../lib/src/message_list_view/message_list_view.dart | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c735532b..624577e0 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -52,6 +52,8 @@ ), ``` +- Added `StreamMessageListView.shrinkWrap` to allow users to shrink wrap the message list view. + 🔄 Changed - Deprecated `MessageTheme.linkBackgroundColor` in favor of `MessageTheme.urlAttachmentBackgroundColor`. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c66c88f8..d1126063 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -115,6 +115,7 @@ class StreamMessageListView extends StatefulWidget { this.unreadMessagesSeparatorBuilder, this.messageListController, this.reverse = true, + this.shrinkWrap = false, this.paginationLimit = 20, this.paginationLoadingIndicatorBuilder, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, @@ -135,6 +136,14 @@ class StreamMessageListView extends StatefulWidget { /// See [ScrollView.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 final int paginationLimit; @@ -550,6 +559,7 @@ class _StreamMessageListViewState extends State { physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, + shrinkWrap: widget.shrinkWrap, itemCount: itemCount, findChildIndexCallback: (Key key) { final indexedKey = key as IndexedKey; From a3bfedc8bfa42f8f7c6a0c706769f22b6d6d9208 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 3 May 2023 16:27:28 +0530 Subject: [PATCH 3/3] chore: fix analysis and format Signed-off-by: xsahil03x --- .../positioned_list_test.dart | 14 +- .../scrollable_positioned_list_test.dart | 168 ++++++++++-------- .../shrink_wrap_position_list_test.dart | 132 +++++++------- ...nk_wrap_scrollable_position_list_test.dart | 7 +- 4 files changed, 171 insertions(+), 150 deletions(-) diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart index 5dadbda0..96e10a4c 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart @@ -391,12 +391,14 @@ void main() { ), )); - // 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); + // 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(find.byType(Overlay)).insert(newOverlay); await tester.pump(); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart index 6419e492..55fe0c7e 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -48,7 +48,10 @@ void main() { itemCount: itemCount, itemScrollController: itemScrollController, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); return SizedBox( height: variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, @@ -1133,34 +1136,35 @@ void main() { }, skip: true); testWidgets( - 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back', - (WidgetTester tester) async { - final itemScrollController = ItemScrollController(); - final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + 'Jump to 400 at bottom, manually scroll, scroll to 100 at bottom and back', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); - itemScrollController.jumpTo(index: 400, alignment: 1); - await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 400, alignment: 1); + await tester.pumpAndSettle(); - final listFinder = find.byType(ScrollablePositionedList); + final listFinder = find.byType(ScrollablePositionedList); - await tester.drag(listFinder, const Offset(0, -screenHeight)); - await tester.pumpAndSettle(); + await tester.drag(listFinder, const Offset(0, -screenHeight)); + await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 100, alignment: 1, duration: scrollDuration)); - await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 400, alignment: 1, duration: scrollDuration)); - await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: 400, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); - final itemFinder = find.text('Item 399'); - expect(itemFinder, findsOneWidget); - expect(tester.getBottomLeft(itemFinder).dy, screenHeight); - }); + final itemFinder = find.text('Item 399'); + expect(itemFinder, findsOneWidget); + expect(tester.getBottomLeft(itemFinder).dy, screenHeight); + }, + ); testWidgets('physics', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1652,70 +1656,71 @@ void main() { }); testWidgets( - 'Maintain programmatic and user position (9 half way off top) in page view', - (WidgetTester tester) async { - final itemPositionsListener = ItemPositionsListener.create(); - final itemScrollController = ItemScrollController(); + 'Maintain programmatic and user position (9 half way off top) in page view', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); - tester.binding.window.devicePixelRatioTestValue = 1.0; - tester.binding.window.physicalSizeTestValue = - const Size(screenWidth, screenHeight); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); - await tester.pumpWidget( - MaterialApp( - home: PageView( - children: [ - KeyedSubtree( - key: const PageStorageKey('key'), - child: ScrollablePositionedList.builder( - itemCount: defaultItemCount, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: const PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, ), - itemPositionsListener: itemPositionsListener, ), - ), - const Center( - child: Text('Test'), - ) - ], + const Center( + child: Text('Test'), + ) + ], + ), ), - ), - ); + ); - itemScrollController.jumpTo(index: 9); - await tester.pump(); + itemScrollController.jumpTo(index: 9); + await tester.pump(); - expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); + expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); - await tester.pumpAndSettle(); + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.pumpAndSettle(); - final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; - expect(item9Bottom, lessThan(itemHeight)); + final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; + expect(item9Bottom, lessThan(itemHeight)); - await tester.drag(find.byType(PageView), const Offset(-500, 0)); - await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(-500, 0)); + await tester.pumpAndSettle(); - await tester.drag(find.byType(PageView), const Offset(500, 0)); - await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(500, 0)); + await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); + expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); - }); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }, + ); testWidgets('List with no items', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); @@ -1746,7 +1751,10 @@ void main() { itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); return SizedBox( height: itemHeight, child: Text('Item $index'), @@ -1788,7 +1796,10 @@ void main() { initialScrollIndex: min(100, itemCount - 1), itemCount: itemCount, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); return SizedBox( height: itemHeight, child: Text('Item $index'), @@ -1827,7 +1838,10 @@ void main() { initialScrollIndex: itemCount - 1, itemCount: itemCount, itemBuilder: (context, index) { - assert(index >= 0 && index <= itemCount - 1); + assert( + index >= 0 && index <= itemCount - 1, + 'index must be in the range of 0 to itemCount - 1', + ); return SizedBox( height: itemHeight, child: Text('Item $index'), diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart index c2839717..c5d94670 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart @@ -31,12 +31,13 @@ void main() { await tester.pumpWidget( MaterialApp( - // Use flex layout to ensure that the minimum height is not limited to screenHeight + // 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), + constraints: const BoxConstraints( + maxHeight: screenHeight, maxWidth: screenWidth), child: PositionedList( key: key, itemCount: itemCount, @@ -292,73 +293,75 @@ void main() { }); 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); + '''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(); + 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(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); - }); + 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); + '''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(); + 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); - }); + 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', @@ -424,7 +427,8 @@ void main() { await tester.pumpWidget( MaterialApp( - // Use flex layout to ensure that the minimum height is not limited to screenHeight + // Use flex layout to ensure that the minimum height is not limited to + // screenHeight. home: PositionedList( itemCount: 5, itemBuilder: (context, index) { diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart index c9a0b06c..cc3725aa 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart @@ -27,12 +27,13 @@ void main() { await tester.pumpWidget( MaterialApp( - // Use flex layout to ensure that the minimum height is not limited to screenHeight + // 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), + constraints: const BoxConstraints( + maxHeight: screenHeight, maxWidth: screenWidth), child: ScrollablePositionedList.builder( itemCount: itemCount, initialScrollIndex: initialIndex,