From 3a7343904d2aa29f38ad7dfb1de86086e935362c Mon Sep 17 00:00:00 2001 From: Gordon Hayes Date: Fri, 15 Oct 2021 15:13:59 +0200 Subject: [PATCH] feat: move scrollable positioned list to internal src --- .../lib/src/message_list_view.dart | 2 +- .../src/scrollable_positioned_list/LICENSE | 26 + .../src/scrollable_positioned_list/README.md | 53 + .../scrollable_positioned_list.dart | 7 + .../src/element_registry.dart | 96 + .../src/indexed_key.dart | 30 + .../src/item_positions_listener.dart | 61 + .../src/item_positions_notifier.dart | 13 + .../src/positioned_list.dart | 359 +++ .../src/post_mount_callback.dart | 35 + .../src/scroll_view.dart | 76 + .../src/scrollable_positioned_list.dart | 591 +++++ .../src/viewport.dart | 315 +++ packages/stream_chat_flutter/pubspec.yaml | 5 - ...ontal_scrollable_positioned_list_test.dart | 298 +++ .../positioned_list_test.dart | 363 +++ .../reversed_positioned_list_test.dart | 270 ++ ...ersed_scrollable_positioned_list_test.dart | 239 ++ .../scrollable_positioned_list_test.dart | 2301 +++++++++++++++++ .../separated_positioned_list_test.dart | 261 ++ ...rated_scrollable_positioned_list_test.dart | 608 +++++ ...ontal_scrollable_positioned_list_test.dart | 220 ++ 22 files changed, 6223 insertions(+), 6 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart create mode 100644 packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart create mode 100644 packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index cb0d75dd..3b2d737d 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -7,10 +7,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/info_tile.dart'; import 'package:stream_chat_flutter/src/message_widget.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/swipeable.dart'; import 'package:stream_chat_flutter/src/system_message.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE new file mode 100644 index 00000000..7cc8ade1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/LICENSE @@ -0,0 +1,26 @@ +Copyright 2018 the Dart project authors, Inc. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md new file mode 100644 index 00000000..41b1f70b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/README.md @@ -0,0 +1,53 @@ +# scrollable_positioned_list + +A flutter list that allows scrolling to a specific item in the list. + +Also allows determining what items are currently visible. + +## Usage + +A `ScrollablePositionedList` works much like the builder version of `ListView` +except that the list can be scrolled or jumped to a specific item. + +### Example + +A `ScrollablePositionedList` can be created with: + +```dart +final ItemScrollController itemScrollController = ItemScrollController(); +final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create(); + +ScrollablePositionedList.builder( + itemCount: 500, + itemBuilder: (context, index) => Text('Item $index'), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, +); +``` + +One then can scroll to a particular item with: + +```dart +itemScrollController.scrollTo( + index: 150, + duration: Duration(seconds: 2), + curve: Curves.easeInOutCubic); +``` + +or jump to a particular item with: + +```dart +itemScrollController.jumpTo(index: 150); +``` + +One can monitor what items are visible on screen with: + +```dart +itemPositionsListener.itemPositions.addListener(() => ...); +``` + +A full example can be found in the example folder. + +-------------------------------------------------------------------------------- + +This is not an officially supported Google product. diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart new file mode 100644 index 00000000..c275b611 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/scrollable_positioned_list.dart @@ -0,0 +1,7 @@ +// 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. + +export 'src/indexed_key.dart'; +export 'src/item_positions_listener.dart'; +export 'src/scrollable_positioned_list.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart new file mode 100644 index 00000000..b24a0c17 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/element_registry.dart @@ -0,0 +1,96 @@ +// 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/widgets.dart'; + +/// A registry to track some [Element]s in the tree. +class RegistryWidget extends StatefulWidget { + /// Creates a [RegistryWidget]. + const RegistryWidget({Key? key, this.elementNotifier, required this.child}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Contains the current set of all [Element]s created by + /// [RegisteredElementWidget]s in the tree below this widget. + /// + /// Note that if there is another [RegistryWidget] in this widget's subtree + /// that registry, and not this one, will collect elements in its subtree. + final ValueNotifier?>? elementNotifier; + + @override + State createState() => _RegistryWidgetState(); +} + +/// A widget whose [Element] will be added its nearest ancestor +/// [RegistryWidget]. +class RegisteredElementWidget extends ProxyWidget { + /// Creates a [RegisteredElementWidget]. + const RegisteredElementWidget({Key? key, required Widget child}) + : super(key: key, child: child); + + @override + Element createElement() => _RegisteredElement(this); +} + +class _RegistryWidgetState extends State { + final Set registeredElements = {}; + + @override + Widget build(BuildContext context) => _InheritedRegistryWidget( + state: this, + child: widget.child, + ); +} + +class _InheritedRegistryWidget extends InheritedWidget { + final _RegistryWidgetState state; + + const _InheritedRegistryWidget( + {Key? key, required this.state, required Widget child}) + : super(key: key, child: child); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) => true; +} + +class _RegisteredElement extends ProxyElement { + _RegisteredElement(ProxyWidget widget) : super(widget); + + @override + void notifyClients(ProxyWidget oldWidget) {} + + late _RegistryWidgetState _registryWidgetState; + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final _inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = _inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final _inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = _inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void unmount() { + _registryWidgetState.registeredElements.remove(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + super.unmount(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart new file mode 100644 index 00000000..689996d0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/indexed_key.dart @@ -0,0 +1,30 @@ +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart'; + +class IndexedKey extends LocalKey { + /// Creates an indexed key that delegates its [operator==] to the given key. + /// + /// It contains an index used in [ScrollablePositionedList]. + const IndexedKey(this.key, this.index); + + /// The key to which this this delegates its [operator==]. + final Key? key; + + /// Index used to show position in a list. + final int index; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is IndexedKey && other.key == key; + } + + @override + int get hashCode => hashValues(runtimeType, key); + + @override + String toString() { + return '(IndexedKey) index: $index, key: $key'; + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart new file mode 100644 index 00000000..dfb3e7d8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_listener.dart @@ -0,0 +1,61 @@ +// 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/foundation.dart'; + +import 'item_positions_notifier.dart'; +import 'scrollable_positioned_list.dart'; + +/// Provides a listenable iterable of [itemPositions] of items that are on +/// screen and their locations. +abstract class ItemPositionsListener { + /// Creates an [ItemPositionsListener] that can be used by a + /// [ScrollablePositionedList] to return the current position of items. + factory ItemPositionsListener.create() => ItemPositionsNotifier(); + + /// The position of items that are at least partially visible in the viewport. + ValueListenable> get itemPositions; +} + +/// Position information for an item in the list. +class ItemPosition { + /// Create an [ItemPosition]. + const ItemPosition( + {required this.index, + required this.itemLeadingEdge, + required this.itemTrailingEdge}); + + /// Index of the item. + final int index; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the leading edge of the item. + /// + /// May be negative if the item is partially visible. + final double itemLeadingEdge; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the trailing edge of the item. + /// + /// May be greater than one if the item is partially visible. + final double itemTrailingEdge; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final ItemPosition otherPosition = other; + return otherPosition.index == index && + otherPosition.itemLeadingEdge == itemLeadingEdge && + otherPosition.itemTrailingEdge == itemTrailingEdge; + } + + @override + int get hashCode => + 31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) + + itemTrailingEdge.hashCode; + + @override + String toString() => + 'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart new file mode 100644 index 00000000..a2b993a6 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/item_positions_notifier.dart @@ -0,0 +1,13 @@ +// 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/foundation.dart'; + +import 'item_positions_listener.dart'; + +/// Internal implementation of [ItemPositionsListener]. +class ItemPositionsNotifier implements ItemPositionsListener { + @override + final ValueNotifier> itemPositions = ValueNotifier([]); +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart new file mode 100644 index 00000000..c53f017e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/positioned_list.dart @@ -0,0 +1,359 @@ +// 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/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'element_registry.dart'; +import 'indexed_key.dart'; +import 'item_positions_listener.dart'; +import 'item_positions_notifier.dart'; +import 'scroll_view.dart'; + +/// A list of widgets similar to [ListView], except scroll control +/// and position reporting is based on index rather than pixel offset. +/// +/// [PositionedList] lays out children in the same way as [ListView]. +/// +/// The list can be displayed with the item at [positionIndex] positioned at a +/// particular [alignment]. See [ItemScrollController.jumpTo] for an +/// explanation of alignment. +/// +/// All other parameters are the same as specified in [ListView]. +class PositionedList extends StatefulWidget { + /// Create a [PositionedList]. + const PositionedList({ + required this.itemCount, + required this.itemBuilder, + this.separatorBuilder, + this.controller, + this.itemPositionsNotifier, + this.positionedIndex = 0, + this.alignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.padding, + this.cacheExtent, + this.semanticChildCount, + this.findChildIndexCallback, + this.addSemanticIndexes = true, + this.addRepaintBoundaries = true, + this.addAutomaticKeepAlives = true, + }) : assert(itemCount != null), + assert(itemBuilder != null), + assert((positionedIndex == 0) || (positionedIndex < 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; + + /// Number of items the [itemBuilder] can produce. + final int itemCount; + + /// Called to build children for the list with + /// 0 <= index < itemCount. + final IndexedWidgetBuilder itemBuilder; + + /// If not null, called to build separators for between each item in the list. + /// Called with 0 <= index < itemCount - 1. + final IndexedWidgetBuilder? separatorBuilder; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + final ScrollController? controller; + + /// Notifier that reports the items laid out in the list after each frame. + final ItemPositionsNotifier? itemPositionsNotifier; + + /// Index of an item to initially align to a position within the viewport + /// defined by [alignment]. + final int positionedIndex; + + /// Determines where the leading edge of the item at [positionedIndex] + /// should be placed. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + final double alignment; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// Whether the view scrolls in the reading direction. + /// + /// Defaults to false. + /// + /// See [ScrollView.reverse]. + final bool reverse; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// See [ScrollView.physics]. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.cacheExtent} + final double? cacheExtent; + + /// The number of children that will contribute semantic information. + /// + /// See [ScrollView.semanticChildCount] for more information. + final int? semanticChildCount; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// See [SliverChildBuilderDelegate.addSemanticIndexes]. + final bool addSemanticIndexes; + + /// The amount of space by which to inset the children. + final EdgeInsets? padding; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// See [SliverChildBuilderDelegate.addRepaintBoundaries]. + final bool addRepaintBoundaries; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. + final bool addAutomaticKeepAlives; + + @override + State createState() => _PositionedListState(); +} + +class _PositionedListState extends State { + final Key _centerKey = UniqueKey(); + + final registeredElements = ValueNotifier?>(null); + late final ScrollController scrollController; + + bool updateScheduled = false; + + @override + void initState() { + super.initState(); + scrollController = widget.controller ?? ScrollController(); + scrollController.addListener(_schedulePositionNotificationUpdate); + _schedulePositionNotificationUpdate(); + } + + @override + void dispose() { + scrollController.removeListener(_schedulePositionNotificationUpdate); + super.dispose(); + } + + @override + void didUpdateWidget(PositionedList oldWidget) { + super.didUpdateWidget(oldWidget); + _schedulePositionNotificationUpdate(); + } + + @override + Widget build(BuildContext context) => RegistryWidget( + elementNotifier: registeredElements, + child: UnboundedCustomScrollView( + anchor: widget.alignment, + center: _centerKey, + controller: scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent, + physics: widget.physics, + semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + slivers: [ + if (widget.positionedIndex > 0) + SliverPadding( + padding: _leadingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(widget.positionedIndex - (index + 1)) + : _buildSeparatedListElement( + 2 * widget.positionedIndex - (index + 1)), + childCount: widget.separatorBuilder == null + ? widget.positionedIndex + : 2 * widget.positionedIndex, + addSemanticIndexes: false, + findChildIndexCallback: widget.findChildIndexCallback, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ), + ), + ), + SliverPadding( + key: _centerKey, + padding: _centerSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex) + : _buildSeparatedListElement( + index + 2 * widget.positionedIndex), + childCount: widget.itemCount != 0 ? 1 : 0, + findChildIndexCallback: widget.findChildIndexCallback, + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + ), + ), + ), + if (widget.positionedIndex >= 0 && + widget.positionedIndex < widget.itemCount - 1) + SliverPadding( + padding: _trailingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex + 1) + : _buildSeparatedListElement( + index + 2 * widget.positionedIndex + 1), + 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, + ), + ), + ), + ], + ), + ); + + Widget _buildSeparatedListElement(int index) { + if (index.isEven) { + return _buildItem(index ~/ 2); + } else { + return widget.separatorBuilder!(context, index ~/ 2); + } + } + + Widget _buildItem(int index) { + final child = widget.itemBuilder(context, index); + return RegisteredElementWidget( + key: IndexedKey(child.key, index), + child: widget.addSemanticIndexes + ? IndexedSemantics(index: index, child: child) + : child, + ); + } + + EdgeInsets get _leadingSliverPadding => + (widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(top: 0) + : widget.padding?.copyWith(bottom: 0) + : widget.reverse + ? widget.padding?.copyWith(left: 0) + : widget.padding?.copyWith(right: 0)) ?? + EdgeInsets.all(0); + + EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith( + top: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.top + : 0, + bottom: widget.positionedIndex == 0 + ? widget.padding!.bottom + : 0) ?? + EdgeInsets.all(0) + : widget.padding?.copyWith( + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.bottom + : 0) ?? + EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith( + left: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.left + : 0, + right: widget.positionedIndex == 0 + ? widget.padding!.right + : 0) ?? + EdgeInsets.all(0) + : widget.padding?.copyWith( + left: widget.positionedIndex == 0 ? widget.padding!.left : 0, + right: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.right + : 0, + ) ?? + EdgeInsets.all(0); + + EdgeInsets get _trailingSliverPadding => + widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0) + : widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0) + : widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0); + + void _schedulePositionNotificationUpdate() { + if (!updateScheduled) { + updateScheduled = true; + SchedulerBinding.instance!.addPostFrameCallback((_) { + if (registeredElements.value == null) { + updateScheduled = false; + return; + } + final positions = []; + RenderViewport? viewport; + for (var element in registeredElements.value!) { + final RenderBox box = element.renderObject as RenderBox; + viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; + final IndexedKey key = element.widget.key as IndexedKey; + if (widget.scrollDirection == Axis.vertical) { + final reveal = viewport!.getOffsetToReveal(box, 0).offset; + final itemOffset = reveal - + viewport.offset.pixels + + viewport.anchor * viewport.size.height; + positions.add(ItemPosition( + index: key.index, + itemLeadingEdge: itemOffset.round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (itemOffset + box.size.height).round() / + scrollController.position.viewportDimension)); + } else { + final itemOffset = + box.localToGlobal(Offset.zero, ancestor: viewport).dx; + positions.add(ItemPosition( + index: key.index, + itemLeadingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + (itemOffset + box.size.width) + : itemOffset) + .round() / + scrollController.position.viewportDimension, + itemTrailingEdge: (widget.reverse + ? scrollController.position.viewportDimension - + itemOffset + : (itemOffset + box.size.width)) + .round() / + scrollController.position.viewportDimension)); + } + } + widget.itemPositionsNotifier?.itemPositions.value = positions; + updateScheduled = false; + }); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart new file mode 100644 index 00000000..9ec553fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/post_mount_callback.dart @@ -0,0 +1,35 @@ +// 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/widgets.dart'; + +/// Widget whose [Element] calls a callback when the element is mounted. +class PostMountCallback extends StatelessWidget { + /// Creates a [PostMountCallback] widget. + const PostMountCallback({required this.child, this.callback, Key? key}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Callback to call when the element for this widget is mounted. + final void Function()? callback; + + @override + StatelessElement createElement() => _PostMountCallbackElement(this); + + @override + Widget build(BuildContext context) => child; +} + +class _PostMountCallbackElement extends StatelessElement { + _PostMountCallbackElement(PostMountCallback widget) : super(widget); + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final PostMountCallback postMountCallback = widget as PostMountCallback; + postMountCallback.callback?.call(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart new file mode 100644 index 00000000..e00462e2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scroll_view.dart @@ -0,0 +1,76 @@ +// 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/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'viewport.dart'; + +/// A version of [CustomScrollView] that allows does not constrict the extents +/// to be within 0 and 1. See [CustomScrollView] for more information. +class UnboundedCustomScrollView extends CustomScrollView { + const UnboundedCustomScrollView({ + Key? key, + Axis scrollDirection = Axis.vertical, + bool reverse = false, + ScrollController? controller, + bool? primary, + ScrollPhysics? physics, + bool shrinkWrap = false, + Key? center, + double anchor = 0.0, + double? cacheExtent, + List slivers = const [], + int? semanticChildCount, + DragStartBehavior dragStartBehavior = DragStartBehavior.start, + }) : _anchor = anchor, + super( + key: key, + scrollDirection: scrollDirection, + reverse: reverse, + controller: controller, + primary: primary, + physics: physics, + shrinkWrap: shrinkWrap, + center: center, + cacheExtent: cacheExtent, + semanticChildCount: semanticChildCount, + dragStartBehavior: dragStartBehavior, + slivers: slivers, + ); + + // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so + // we need our own version. + final double _anchor; + + @override + double get anchor => _anchor; + + /// Build the viewport. + @override + @protected + Widget buildViewport( + BuildContext context, + ViewportOffset offset, + AxisDirection axisDirection, + List slivers, + ) { + if (shrinkWrap) { + return ShrinkWrappingViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + ); + } + return UnboundedViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart new file mode 100644 index 00000000..d262578b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -0,0 +1,591 @@ +// 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 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'item_positions_listener.dart'; +import 'item_positions_notifier.dart'; +import 'positioned_list.dart'; +import 'post_mount_callback.dart'; + +/// Number of screens to scroll when scrolling a long distance. +const int _screenScrollCount = 2; + +/// A scrollable list of widgets similar to [ListView], except scroll control +/// and position reporting is based on index rather than pixel offset. +/// +/// [ScrollablePositionedList] lays out children in the same way as [ListView]. +/// +/// The list can be displayed with the item at [initialScrollIndex] positioned +/// at a particular [initialAlignment]. +/// +/// The [itemScrollController] can be used to scroll or jump to particular items +/// in the list. The [itemPositionsNotifier] can be used to get a list of items +/// currently laid out by the list. +/// +/// All other parameters are the same as specified in [ListView]. +class ScrollablePositionedList extends StatefulWidget { + /// Create a [ScrollablePositionedList] whose items are provided by + /// [itemBuilder]. + const ScrollablePositionedList.builder({ + required this.itemCount, + required this.itemBuilder, + Key? key, + this.itemScrollController, + ItemPositionsListener? itemPositionsListener, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.semanticChildCount, + this.padding, + this.addSemanticIndexes = true, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.minCacheExtent, + this.findChildIndexCallback, + }) : assert(itemCount != null), + assert(itemBuilder != null), + itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + separatorBuilder = null, + super(key: key); + + /// Create a [ScrollablePositionedList] whose items are provided by + /// [itemBuilder] and separators provided by [separatorBuilder]. + const ScrollablePositionedList.separated({ + required this.itemCount, + required this.itemBuilder, + required this.separatorBuilder, + Key? key, + this.itemScrollController, + ItemPositionsListener? itemPositionsListener, + this.initialScrollIndex = 0, + this.initialAlignment = 0, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.physics, + this.semanticChildCount, + this.padding, + this.addSemanticIndexes = true, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.minCacheExtent, + this.findChildIndexCallback, + }) : assert(itemCount != null), + assert(itemBuilder != null), + assert(separatorBuilder != null), + itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + super(key: key); + + /// 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; + + /// Number of items the [itemBuilder] can produce. + final int itemCount; + + /// Called to build children for the list with + /// 0 <= index < itemCount. + final IndexedWidgetBuilder itemBuilder; + + /// Called to build separators for between each item in the list. + /// Called with 0 <= index < itemCount - 1. + final IndexedWidgetBuilder? separatorBuilder; + + /// Controller for jumping or scrolling to an item. + final ItemScrollController? itemScrollController; + + /// Notifier that reports the items laid out in the list after each frame. + final ItemPositionsNotifier? itemPositionsNotifier; + + /// Index of an item to initially align within the viewport. + final int initialScrollIndex; + + /// Determines where the leading edge of the item at [initialScrollIndex] + /// should be placed. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + final double initialAlignment; + + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + final Axis scrollDirection; + + /// Whether the view scrolls in the reading direction. + /// + /// Defaults to false. + /// + /// See [ScrollView.reverse]. + final bool reverse; + + /// How the scroll view should respond to user input. + /// + /// For example, determines how the scroll view continues to animate after the + /// user stops dragging the scroll view. + /// + /// See [ScrollView.physics]. + final ScrollPhysics? physics; + + /// The number of children that will contribute semantic information. + /// + /// See [ScrollView.semanticChildCount] for more information. + final int? semanticChildCount; + + /// The amount of space by which to inset the children. + final EdgeInsets? padding; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// See [SliverChildBuilderDelegate.addSemanticIndexes]. + final bool addSemanticIndexes; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// See [SliverChildBuilderDelegate.addRepaintBoundaries]. + final bool addRepaintBoundaries; + + /// The minimum cache extent used by the underlying scroll lists. + /// See [ScrollView.cacheExtent]. + /// + /// Note that the [ScrollablePositionedList] uses two lists to simulate long + /// scrolls, so using the [ScrollController.scrollTo] method may result + /// in builds of widgets that would otherwise already be built in the + /// cache extent. + final double? minCacheExtent; + + @override + State createState() => _ScrollablePositionedListState(); +} + +/// Controller to jump or scroll to a particular position in a +/// [ScrollablePositionedList]. +class ItemScrollController { + /// Whether any ScrollablePositionedList objects are attached this object. + /// + /// If `false`, then [jumpTo] and [scrollTo] must not be called. + bool get isAttached => _scrollableListState != null; + + _ScrollablePositionedListState? _scrollableListState; + + /// Immediately, without animation, reconfigure the list so that the item at + /// [index]'s leading edge is at the given [alignment]. + /// + /// The [alignment] specifies the desired position for the leading edge of the + /// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\] + /// and represents a proportion along the main axis of the viewport. + /// + /// For a vertically scrolling view that is not reversed: + /// * 0 aligns the top edge of the item with the top edge of the view. + /// * 1 aligns the top edge of the item with the bottom of the view. + /// * 0.5 aligns the top edge of the item with the center of the view. + /// + /// For a horizontally scrolling view that is not reversed: + /// * 0 aligns the left edge of the item with the left edge of the view + /// * 1 aligns the left edge of the item with the right edge of the view. + /// * 0.5 aligns the left edge of the item with the center of the view. + void jumpTo({required int index, double alignment = 0}) { + _scrollableListState!._jumpTo(index: index, alignment: alignment); + } + + /// Animate the list over [duration] using the given [curve] such that the + /// item at [index] ends up with its leading edge at the given [alignment]. + /// See [jumpTo] for an explanation of alignment. + /// + /// The [duration] must be greater than 0; otherwise, use [jumpTo]. + /// + /// When item position is not available, because it's too far, the scroll + /// is composed into three phases: + /// + /// 1. The currently displayed list view starts scrolling. + /// 2. Another list view, which scrolls with the same speed, fades over the + /// first one and shows items that are close to the scroll target. + /// 3. The second list view scrolls and stops on the target. + /// + /// The [opacityAnimationWeights] can be used to apply custom weights to these + /// three stages of this animation. The default weights, `[40, 20, 40]`, are + /// good with default [Curves.linear]. Different weights might be better for + /// other cases. For example, if you use [Curves.easeOut], consider setting + /// [opacityAnimationWeights] to `[20, 20, 60]`. + /// + /// See [TweenSequenceItem.weight] for more info. + Future scrollTo({ + required int index, + double alignment = 0, + required Duration duration, + Curve curve = Curves.linear, + List opacityAnimationWeights = const [40, 20, 40], + }) { + assert(_scrollableListState != null); + assert(opacityAnimationWeights.length == 3); + assert(duration > Duration.zero); + return _scrollableListState!._scrollTo( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + + void _attach(_ScrollablePositionedListState scrollableListState) { + assert(_scrollableListState == null); + _scrollableListState = scrollableListState; + } + + void _detach() { + _scrollableListState = null; + } +} + +class _ScrollablePositionedListState extends State + with TickerProviderStateMixin { + /// Details for the primary (active) [ListView]. + var primary = _ListDisplayDetails(const ValueKey('Ping')); + + /// Details for the secondary (transitional) [ListView] that is temporarily + /// shown when scrolling a long distance. + var secondary = _ListDisplayDetails(const ValueKey('Pong')); + + final opacity = ProxyAnimation(const AlwaysStoppedAnimation(0)); + + void Function() startAnimationCallback = () {}; + + bool _isTransitioning = false; + + @override + void initState() { + super.initState(); + ItemPosition? initialPosition = PageStorage.of(context)!.readState(context); + primary.target = initialPosition?.index ?? widget.initialScrollIndex; + primary.alignment = + initialPosition?.itemLeadingEdge ?? widget.initialAlignment; + if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) { + primary.target = widget.itemCount - 1; + } + widget.itemScrollController?._attach(this); + primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions); + secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions); + } + + @override + void deactivate() { + widget.itemScrollController?._detach(); + super.deactivate(); + } + + @override + void dispose() { + primary.itemPositionsNotifier.itemPositions + .removeListener(_updatePositions); + secondary.itemPositionsNotifier.itemPositions + .removeListener(_updatePositions); + super.dispose(); + } + + @override + void didUpdateWidget(ScrollablePositionedList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.itemScrollController?._scrollableListState == this) { + oldWidget.itemScrollController?._detach(); + } + if (widget.itemScrollController?._scrollableListState != this) { + widget.itemScrollController?._detach(); + widget.itemScrollController?._attach(this); + } + + if (widget.itemCount == 0) { + setState(() { + primary.target = 0; + secondary.target = 0; + }); + } else { + if (primary.target > widget.itemCount - 1) { + setState(() { + primary.target = widget.itemCount - 1; + }); + } + if (secondary.target > widget.itemCount - 1) { + setState(() { + secondary.target = widget.itemCount - 1; + }); + } + } + } + + @override + 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, + addSemanticIndexes: widget.addSemanticIndexes, + semanticChildCount: widget.semanticChildCount, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + findChildIndexCallback: widget.findChildIndexCallback, + ), + ), + ), + ), + if (_isTransitioning) + PostMountCallback( + key: secondary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: opacity, + child: NotificationListener( + onNotification: (_) => false, + child: PositionedList( + 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.minCacheExtent ?? 0, + ); + + void _jumpTo({required int index, required double alignment}) { + _stopScroll(canceled: true); + if (index > widget.itemCount - 1) { + index = widget.itemCount - 1; + } + setState(() { + primary.scrollController.jumpTo(0); + primary.target = index; + primary.alignment = alignment; + }); + } + + Future _scrollTo({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + if (index > widget.itemCount - 1) { + index = widget.itemCount - 1; + } + if (_isTransitioning) { + _stopScroll(canceled: true); + SchedulerBinding.instance!.addPostFrameCallback((_) { + _startScroll( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + }); + } else { + await _startScroll( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + } + + Future _startScroll({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + final direction = index > primary.target ? 1 : -1; + final itemPosition = primary.itemPositionsNotifier.itemPositions.value + .firstWhereOrNull( + (ItemPosition itemPosition) => itemPosition.index == index); + if (itemPosition != null) { + // Scroll directly. + final localScrollAmount = itemPosition.itemLeadingEdge * + primary.scrollController.position.viewportDimension; + await primary.scrollController.animateTo( + primary.scrollController.offset + + localScrollAmount - + alignment * primary.scrollController.position.viewportDimension, + duration: duration, + curve: curve); + } else { + final scrollAmount = _screenScrollCount * + primary.scrollController.position.viewportDimension; + final startCompleter = Completer(); + final endCompleter = Completer(); + startAnimationCallback = () { + SchedulerBinding.instance!.addPostFrameCallback((_) { + startAnimationCallback = () {}; + + opacity.parent = _opacityAnimation(opacityAnimationWeights).animate( + AnimationController(vsync: this, duration: duration)..forward()); + secondary.scrollController.jumpTo(-direction * + (_screenScrollCount * + primary.scrollController.position.viewportDimension - + alignment * + secondary.scrollController.position.viewportDimension)); + + startCompleter.complete(primary.scrollController.animateTo( + primary.scrollController.offset + direction * scrollAmount, + duration: duration, + curve: curve)); + endCompleter.complete(secondary.scrollController + .animateTo(0, duration: duration, curve: curve)); + }); + }; + setState(() { + // TODO: _startScroll can be re-entrant, which invalidates this assert. + // assert(!_isTransitioning); + secondary.target = index; + secondary.alignment = alignment; + _isTransitioning = true; + }); + await Future.wait([startCompleter.future, endCompleter.future]); + _stopScroll(); + } + } + + void _stopScroll({bool canceled = false}) { + if (!_isTransitioning) { + return; + } + + if (canceled) { + if (primary.scrollController.hasClients) { + primary.scrollController.jumpTo(primary.scrollController.offset); + } + if (secondary.scrollController.hasClients) { + secondary.scrollController.jumpTo(secondary.scrollController.offset); + } + } + + setState(() { + if (opacity.value >= 0.5) { + // Secondary [ListView] is more visible than the primary; make it the + // new primary. + var temp = primary; + primary = secondary; + secondary = temp; + } + _isTransitioning = false; + opacity.parent = const AlwaysStoppedAnimation(0); + }); + } + + Animatable _opacityAnimation(List opacityAnimationWeights) { + final startOpacity = 0.0; + final endOpacity = 1.0; + return TweenSequence(>[ + TweenSequenceItem( + tween: ConstantTween(startOpacity), + weight: opacityAnimationWeights[0]), + TweenSequenceItem( + tween: Tween(begin: startOpacity, end: endOpacity), + weight: opacityAnimationWeights[1]), + TweenSequenceItem( + tween: ConstantTween(endOpacity), + weight: opacityAnimationWeights[2]), + ]); + } + + void _updatePositions() { + final itemPositions = primary.itemPositionsNotifier.itemPositions.value + .where((ItemPosition position) => + position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0); + if (itemPositions.isNotEmpty) { + PageStorage.of(context)!.writeState( + context, + itemPositions.reduce((value, element) => + value.itemLeadingEdge < element.itemLeadingEdge + ? value + : element)); + } + widget.itemPositionsNotifier?.itemPositions.value = itemPositions; + } +} + +class _ListDisplayDetails { + _ListDisplayDetails(this.key); + + final itemPositionsNotifier = ItemPositionsNotifier(); + final scrollController = ScrollController(keepScrollOffset: false); + + /// The index of the item to scroll to. + int target = 0; + + /// The desired alignment for [target]. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + double alignment = 0; + + final Key key; +} diff --git a/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart new file mode 100644 index 00000000..1de5ff05 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scrollable_positioned_list/src/viewport.dart @@ -0,0 +1,315 @@ +// 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 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A render object that is bigger on the inside. +/// +/// Version of [Viewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [Viewport] +/// for more information. +class UnboundedViewport extends Viewport { + UnboundedViewport({ + Key? key, + AxisDirection axisDirection = AxisDirection.down, + AxisDirection? crossAxisDirection, + double anchor = 0.0, + required ViewportOffset offset, + Key? center, + double? cacheExtent, + List slivers = const [], + }) : _anchor = anchor, + super( + key: key, + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + slivers: slivers); + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + 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. +/// +/// Version of [RenderViewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [RenderViewport] +/// for more information. +/// +// Differences from [RenderViewport] are marked with a //***** Differences +// comment. +class UnboundedRenderViewport extends RenderViewport { + /// Creates a viewport for [RenderSliver] objects. + UnboundedRenderViewport({ + AxisDirection axisDirection = AxisDirection.down, + required AxisDirection crossAxisDirection, + required ViewportOffset offset, + double anchor = 0.0, + List? children, + RenderSliver? center, + double? cacheExtent, + }) : _anchor = anchor, + super( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + children: children); + + static const int _maxLayoutCycles = 10; + + double _anchor; + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + /// 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; + + @override + double get anchor => _anchor; + + @override + set anchor(double value) { + assert(value != null); + if (value == _anchor) return; + _anchor = value; + markNeedsLayout(); + } + + @override + void performResize() { + super.performResize(); + // TODO: Figure out why this override is needed as a result of + // https://github.com/flutter/flutter/pull/61973 and see if it can be + // removed somehow. + switch (axis) { + case Axis.vertical: + offset.applyViewportDimension(size.height); + break; + case Axis.horizontal: + offset.applyViewportDimension(size.width); + break; + } + } + + @override + Rect describeSemanticsClip(RenderSliver? child) { + assert(axis != null); + + if (_calculatedCacheExtent == null) { + return semanticBounds; + } + + switch (axis) { + case Axis.vertical: + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - _calculatedCacheExtent!, + semanticBounds.right, + semanticBounds.bottom + _calculatedCacheExtent!, + ); + default: + return Rect.fromLTRB( + semanticBounds.left - _calculatedCacheExtent!, + semanticBounds.top, + semanticBounds.right + _calculatedCacheExtent!, + semanticBounds.bottom, + ); + } + } + + @override + void performLayout() { + if (center == null) { + assert(firstChild == null); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0.0, 0.0); + return; + } + assert(center!.parent == this); + + late double mainAxisExtent; + late double crossAxisExtent; + switch (axis) { + case Axis.vertical: + mainAxisExtent = size.height; + crossAxisExtent = size.width; + break; + case Axis.horizontal: + mainAxisExtent = size.width; + crossAxisExtent = size.height; + break; + } + + final centerOffsetAdjustment = center!.centerOffsetAdjustment; + + double correction; + var count = 0; + do { + assert(offset.pixels != null); + correction = _attemptLayout(mainAxisExtent, crossAxisExtent, + offset.pixels + centerOffsetAdjustment); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + // *** Difference from [RenderViewport]. + final top = _minScrollExtent + mainAxisExtent * anchor; + final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); + final maxScrollOffset = math.max(math.min(0.0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) + break; + // *** End of difference from [RenderViewport]. + } + count += 1; + } while (count < _maxLayoutCycles); + assert(() { + if (count >= _maxLayoutCycles) { + 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 ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.'); + } + return true; + }()); + } + + double _attemptLayout( + double mainAxisExtent, double crossAxisExtent, double correctedOffset) { + assert(!mainAxisExtent.isNaN); + assert(mainAxisExtent >= 0.0); + assert(crossAxisExtent.isFinite); + assert(crossAxisExtent >= 0.0); + assert(correctedOffset.isFinite); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + + // 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 double centerOffset = mainAxisExtent * anchor - correctedOffset; + final double reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final double forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final double reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final double forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final RenderSliver? leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final double result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0.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.0, -centerOffset), + overlap: + leadingNegativeChild == null ? math.min(0.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; + } +} diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 031a8f2e..ef34d854 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -34,11 +34,6 @@ dependencies: photo_manager: ^1.2.6+1 photo_view: ^0.12.0 rxdart: ^0.27.0 - scrollable_positioned_list: - git: - url: https://github.com/HayesGordon/flutter.widgets.git - ref: feat/child-index-callback - path: packages/scrollable_positioned_list share_plus: ^2.0.3 shimmer: ^2.0.0 stream_chat_flutter_core: ^3.1.1 diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..61b22aa2 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -0,0 +1,298 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemWidth = screenWidth / 10.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + bool reverse = false, + EdgeInsets? padding, + int initialScrollIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + width: itemWidth, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + scrollDirection: Axis.horizontal, + reverse: reverse, + padding: padding, + initialScrollIndex: initialScrollIndex, + ), + ), + ); + } + + testWidgets('List positioned with 0 at left', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getTopLeft(find.text('Item 0')).dx, 0); + expect(tester.getBottomRight(find.text('Item 9')).dx, screenWidth); + 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 == 0) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 0 at right', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, reverse: true); + + expect(tester.getBottomRight(find.text('Item 0')).dx, screenWidth); + expect(tester.getTopLeft(find.text('Item 9')).dx, 0); + 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 == 0) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + 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')).dx, 0); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (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 == 100) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('Jump to 100', (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')).dx, 0); + expect(tester.getBottomRight(find.text('Item 109')).dy, screenWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemTrailingEdge, + 1 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 109) + .itemTrailingEdge, + 1); + }); + + testWidgets('padding test - centered sliver at left', + (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 + itemWidth, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + 2 * itemWidth, screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-100, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(200, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10 + 2 * itemWidth, 10)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10 + 3 * itemWidth, 10)); + }); + + testWidgets('padding test - reversed - centered sliver at right', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + reverse: true, + ); + + expect(tester.getTopRight(find.text('Item 0')), + const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - (10 + itemWidth), 10)); + expect(tester.getBottomLeft(find.text('Item 1')), + const Offset(screenWidth - (10 + 2 * itemWidth), screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(100, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10)); + }); + + testWidgets('padding test - reversed - centered sliver not at right', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + reverse: true, + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopRight(find.text('Item 0')), + const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 2')), + const Offset(screenWidth - (10 + 2 * itemWidth), 10)); + expect(tester.getTopRight(find.text('Item 3')), + const Offset(screenWidth - (10 + 3 * itemWidth), 10)); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart new file mode 100644 index 00000000..983f249f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/positioned_list_test.dart @@ -0,0 +1,363 @@ +// 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/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; +const cacheExtent = itemHeight * 2; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + cacheExtent: cacheExtent, + ), + ), + ); + } + + testWidgets('short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 5); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1 / 2); + }); + + testWidgets('List positioned with 0 at top', (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', (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', (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', + (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', + (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', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 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 1/2', + (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', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, scrollController: scrollController); + await tester.pump(); + + scrollController.jumpTo(5 * itemHeight); + 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', + (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', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(20 * itemHeight); + 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', + (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); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart new file mode 100644 index 00000000..98979df1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -0,0 +1,270 @@ +// 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/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/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, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + reverse: true, + ), + ), + ); + } + + testWidgets('short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 5); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemTrailingEdge, + 1 / 2); + }); + + testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 9')).dy, 0); + 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); + }); + + testWidgets('List positioned with 5 at bottom', (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 15 at bottom', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 15, anchor: 0); + await tester.pump(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 15'), findsOneWidget); + expect(find.text('Item 24'), findsOneWidget); + expect(find.text('Item 25'), findsNothing); + }); + + testWidgets('List positioned with 15 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 15, anchor: 1); + await tester.pump(); + + expect(find.text('Item 15'), findsNothing); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 4'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemTrailingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 14) + .itemLeadingEdge, + 9 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 5 at bottom then scroll up 2', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + await tester.pump(); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemTrailingEdge, + 1 / 10); + }); + + testWidgets('List positioned with 0 at bottom scroll to item 5', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, scrollController: scrollController); + await tester.pump(); + + scrollController.jumpTo(5 * itemHeight); + 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 bottom then scroll up 2 programatically', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(2 * itemHeight); + await tester.pump(); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + expect(find.text('Item 16'), findsOneWidget); + expect(find.text('Item 17'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 16) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at bottom and initial scroll offset', + (WidgetTester tester) async { + final scrollController = + ScrollController(initialScrollOffset: 2 * itemHeight); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + expect(find.text('Item 6'), findsNothing); + expect(find.text('Item 7'), findsOneWidget); + expect(find.text('Item 16'), findsOneWidget); + expect(find.text('Item 17'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 7) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 16) + .itemTrailingEdge, + 1); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..f93bcf71 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -0,0 +1,239 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/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( + home: ScrollablePositionedList.builder( + itemCount: itemCount, + initialScrollIndex: initialIndex, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + reverse: true, + padding: padding, + ), + ), + ); + } + + testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 9')).dy, 0); + 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)', + (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.getBottomRight(find.text('Item 1')).dy, screenHeight); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(tester.getBottomRight(find.text('Item 2')).dy, screenHeight); + + 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', + (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)', + (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', (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.getBottomRight(find.text('Item 100')).dy, screenHeight); + expect(tester.getTopLeft(find.text('Item 109')).dy, 0); + + 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', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + expect(tester.getBottomLeft(find.text('Item 0')), + const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 1')), + const Offset(10, screenHeight - (10 + itemHeight))); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, screenHeight - (10 + 2 * itemHeight))); + + 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, 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.getBottomLeft(find.text('Item 0')), + const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 2')), + const Offset(10, screenHeight - (10 + 2 * itemHeight))); + expect(tester.getBottomLeft(find.text('Item 3')), + const Offset(10, screenHeight - (10 + 3 * itemHeight))); + }); +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart new file mode 100644 index 00000000..bfdfa762 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -0,0 +1,2301 @@ +// 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 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; +const scrollDuration = Duration(seconds: 1); +const scrollDurationTolerance = Duration(milliseconds: 1); +const tolerance = 1e-3; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + Key? key, + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + int initialIndex = 0, + double initialAlignment = 0.0, + int itemCount = defaultItemCount, + ScrollPhysics? physics, + bool addSemanticIndexes = true, + int? semanticChildCount, + EdgeInsets? padding, + bool addRepaintBoundaries = true, + bool addAutomaticKeepAlives = true, + double? minCacheExtent, + bool variableHeight = false, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + key: key, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: + variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, + child: Text('Item $index'), + ); + }, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, + physics: physics, + addSemanticIndexes: addSemanticIndexes, + semanticChildCount: semanticChildCount, + padding: padding, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + minCacheExtent: minCacheExtent, + ), + ), + ); + } + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + 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); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 10), + isEmpty); + }); + + testWidgets('List positioned with 0 at top - use default values', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + ); + + 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('List positioned with 5 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, initialIndex: 5); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 4), + isEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('List positioned with 9 half way off top', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: -(itemHeight / screenHeight) / 2); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 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('Scroll to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + expect(itemScrollController.isAttached, false); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + expect(itemScrollController.isAttached, true); + + unawaited(itemScrollController.scrollTo( + index: 9, + duration: scrollDuration, + alignment: -(itemHeight / screenHeight) / 2)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 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('Jump to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 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 positioned with 9 at middle scroll to 15 at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + unawaited(itemScrollController.scrollTo( + index: 16, duration: scrollDuration, alignment: 1.0)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomRight(find.text('Item 15')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemTrailingEdge, + 1.0); + }); + + testWidgets('Scroll to 1 (already on screen)', (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 ~/ 2); + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + await tester.pump(scrollDuration ~/ 2); + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 10'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 11), + isEmpty); + }); + + testWidgets('Scroll to 1 then 2 (both already on screen)', + (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(find.text('Item 1'), findsOneWidget); + + unawaited( + itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + await tester.pump(); + await tester.pump(scrollDuration); + + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 2'), findsOneWidget); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 1), + isEmpty); + 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', + (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.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + 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 20 without fading', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + 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(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (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.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + 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); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 (not already on screen) front scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + var fadeTransitionFinder = find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0, 0.01), + ); + + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(0.5, 0.01), + ); + + await tester.pump(scrollDuration ~/ 2 - scrollDurationTolerance); + expect(fadeTransitionFinder.evaluate().length, 2); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); + + await tester.pumpAndSettle(); + + expect(fadeTransitionFinder.evaluate().length, 1); + expect( + tester.widget(fadeTransitionFinder.last).opacity.value, + closeTo(1, 0.01), + ); + }); + + testWidgets('Scroll to 100 (not already on screen) back scroll view', + (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); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + await tester.pumpAndSettle(); + expect(find.text('Item 25', skipOffstage: false), findsNothing); + }); + + testWidgets('Scroll to 100 (not already on screen) then back to 0', + (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.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + expect(find.text('Item 0'), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + 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)); + 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)); + + expect(find.text('Item 0'), findsOneWidget); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + 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 then back to 0 back scroll view', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 then back to 0 front scroll view', + (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 + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + 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)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll 100-0-100', (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 + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + + await tester.pumpAndSettle(); + }); + + testWidgets('Jump to 100', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(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('Jump to 100 and position at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100, alignment: 1.0); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 99) + .itemTrailingEdge, + 1.0); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 100), + isEmpty); + }); + + testWidgets('Jump to 100 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + itemScrollController.jumpTo(index: 100, alignment: 0.5); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Manually scroll a significant distance, jump to 100', + (WidgetTester tester) async { + // Test for https://github.com/google/flutter.widgets/issues/144. + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + variableHeight: true); + + final listFinder = find.byType(ScrollablePositionedList); + for (var i = 0; i < 5; i += 1) { + await tester.drag(listFinder, const Offset(0, -screenHeight)); + await tester.pumpAndSettle(); + } + + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + }, skip: true); + + testWidgets('Scroll to 100 and position at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 1.0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 99) + .itemTrailingEdge, + 1.0); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 100), + isEmpty); + }); + + testWidgets('Scroll to 100 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 100, alignment: 0.5, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Scroll to 9 and position at middle', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + unawaited(itemScrollController.scrollTo( + index: 9, alignment: 0.5, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + }); + + testWidgets('Scroll up a little then jump to 100', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.pumpAndSettle(); + + itemScrollController.jumpTo(index: 100); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(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('Scroll to 100 Jump to 0 Scroll to 100', + (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 + scrollDurationTolerance); + + itemScrollController.jumpTo(index: 0); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + expect(tester.getTopLeft(find.text('Item 10')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 19')).dy, screenHeight); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); + }); + + testWidgets('Scroll to 100 stop before half way', + (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 - scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + 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); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 stop half way', (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); + + await tester.tap(find.byType(ScrollablePositionedList)); + 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); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop before half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 - scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 91')).dy, 0); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 stop after half way', (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 + scrollDuration ~/ 20); + + expect(find.text('Item 9', skipOffstage: false), findsOneWidget); + expect(tester.getBottomLeft(find.text('Item 100')).dy, + closeTo(screenHeight, tolerance)); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getBottomLeft(find.text('Item 100')).dy, + closeTo(screenHeight, tolerance)); + expect(find.text('Item 9', skipOffstage: false), findsNothing); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop after half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, closeTo(0, tolerance)); + final fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity.value, 1.0); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 0 stop half way', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + await tester.tap(find.byType(ScrollablePositionedList)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 90')).dy, 0); + expect(find.byType(FadeTransition), findsNWidgets(2)); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 jump to 250 half way', + (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); + + itemScrollController.jumpTo(index: 250); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 250')).dy, 0); + + expect(find.text('Item 100'), findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 250, scroll to 100, jump to 0 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + itemScrollController.jumpTo(index: 0); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(find.text('Item 100'), findsNothing); + + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll to 100 scroll to 250 half way', + (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); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 250')).dy, 0); + expect(find.text('Item 100'), findsNothing); + }); + + testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + expect(find.text('Item 100'), findsNothing); + }); + + testWidgets( + 'Scroll to 100, scroll to 200, then scroll to 300 without waiting', + (WidgetTester tester) async { + // Possibly https://github.com/google/flutter.widgets/issues/171. + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited( + itemScrollController.scrollTo(index: 200, duration: scrollDuration)); + unawaited( + itemScrollController.scrollTo(index: 300, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(find.text('Item 200'), findsNothing); + + var itemFinder = find.text('Item 300'); + expect(itemFinder, findsOneWidget); + expect(tester.getTopLeft(itemFinder).dy, 0); + }, 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); + + itemScrollController.jumpTo(index: 400, alignment: 1); + await tester.pumpAndSettle(); + + final listFinder = find.byType(ScrollablePositionedList); + + 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: 400, alignment: 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + var itemFinder = find.text('Item 399'); + expect(itemFinder, findsOneWidget); + expect(tester.getBottomLeft(itemFinder).dy, screenHeight); + }); + + testWidgets('physics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + physics: const BouncingScrollPhysics()); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('correct index sematics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, initialIndex: 5); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3 = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3.index, 3); + final indexSemantics4 = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4.index, 4); + final indexSemantics5 = tester.widget(find.ancestor( + of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics5.index, 5); + final indexSemantics6 = tester.widget(find.ancestor( + of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics6.index, 6); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3b = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3b.index, 3); + final indexSemantics4b = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4b.index, 4); + final indexSemantics5b = tester.widget(find.ancestor( + of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics5b.index, 5); + final indexSemantics6b = tester.widget(find.ancestor( + of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics6b.index, 6); + }); + + testWidgets('addIndexSemantics = false', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 5, + addSemanticIndexes: false, + ); + + expect(find.byType(IndexedSemantics), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + expect(find.byType(IndexedSemantics), findsNothing); + }); + + testWidgets('semanticChildCount specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + semanticChildCount: 30, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, 30); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, 30); + }); + + testWidgets('semanticChildCount not specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, defaultItemCount); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, defaultItemCount); + }); + + testWidgets('Semantic tree contains items in cache', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + final root = WidgetsBinding + .instance!.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + + final semanticNodes = [root]; + + collectSemanticNodes(root, semanticNodes); + + expect(semanticNodes.where((element) => element.label == 'Item 10'), + isNotEmpty); + }); + + testWidgets('padding test - centered at top', (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, 10 + itemHeight)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, 10 + itemHeight)); + + 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.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered not at top', + (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 + 2 * itemHeight)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10, 10 + 3 * itemHeight)); + }); + + testWidgets('padding - first element centered - scroll up', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + }); + + testWidgets('padding - last element centered - scroll down', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + padding: const EdgeInsets.all(10), + ); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')), + const Offset(10, screenHeight - 10), + ); + }); + + testWidgets('no repaint boundaries', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addRepaintBoundaries: false, + ); + + expect( + tester + .widgetList(find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(RepaintBoundary))) + .length, + lessThan(5)); + }); + + testWidgets('no automatic keep alives', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addAutomaticKeepAlives: false, + ); + + expect( + find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(AutomaticKeepAlive)), + findsNothing); + }); + + testWidgets('Jump to end of list', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Scroll to end of list', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Scroll to end of list, jump to beginning, jump to end', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets('Jump to end of list, scroll to beginning, scroll to end', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + await tester.pumpAndSettle(); + unawaited(itemScrollController.scrollTo( + index: defaultItemCount - 1, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, + screenHeight); + }); + + testWidgets( + 'Jump to end of list, jump to beginning with alignment not at top', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, itemScrollController: itemScrollController); + + itemScrollController.jumpTo(index: defaultItemCount - 1); + await tester.pumpAndSettle(); + + itemScrollController.jumpTo(index: 0, alignment: 0.3); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets("Short list, can't scroll past end", (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, itemCount: 3); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('List can be keyed', (WidgetTester tester) async { + final key = ValueKey('key'); + + await setUpWidgetTest(tester, key: key); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets( + 'Maintain programmatic 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); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + 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.getTopLeft(find.text('Item 9')).dy, -itemHeight / 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('Maintain user scroll position (1 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); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.pumpAndSettle(); + + final item0Bottom = tester.getBottomRight(find.text('Item 0')).dy; + expect(item0Bottom, 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(); + + expect(tester.getBottomRight(find.text('Item 0')).dy, item0Bottom); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + -(itemHeight / screenHeight) / 2); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemTrailingEdge, + (itemHeight / screenHeight) / 2); + }); + + testWidgets( + '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); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + children: [ + KeyedSubtree( + key: PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + Center( + child: Text('Test'), + ) + ], + ), + ), + ); + + itemScrollController.jumpTo(index: 9); + await tester.pump(); + + expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); + + 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)); + + 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( + 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(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, itemCount: 0); + + expect(find.text('Item 0'), findsNothing); + }); + + testWidgets('Jump to 100 then set itemCount to 0', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + initialAlignment: 0, + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 100); + await tester.pumpAndSettle(); + expect(find.text('Item 100'), findsOneWidget); + + itemCount.value = 0; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Item 100'), findsNothing); + expect(itemPositionsListener.itemPositions.value.isEmpty, isTrue); + }); + + testWidgets('List positioned with 100 at top then set itemCount to 100', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + initialAlignment: 0, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 100')).dy, 0); + + itemCount.value = 100; + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + }); + + testWidgets('List positioned with 499 at bottom then set itemCount to 100', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemCount = ValueNotifier(defaultItemCount); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + 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'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(tester.getBottomRight(find.text('Item 499')).dy, screenHeight); + + itemCount.value = 100; + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsNothing); + expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); + }); + + testWidgets('Large minCacheExtent', (WidgetTester tester) async { + await setUpWidgetTest(tester, minCacheExtent: 200 * itemHeight); + + expect(find.text('Item 100', skipOffstage: false), findsOneWidget); + }); + + testWidgets('Scroll to 20 without fading small minCacheExtent', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + minCacheExtent: 10, + ); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + 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(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 14'), findsNothing); + expect(find.text('Item 20'), findsOneWidget); + }); + + testWidgets('Scroll to 100 without fading for large minCacheExtent', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + minCacheExtent: 200 * itemHeight, + ); + + var fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + final initialOpacity = fadeTransition.opacity; + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration ~/ 2); + + fadeTransition = tester.widget(find + .descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(FadeTransition)) + .last); + expect(fadeTransition.opacity, initialOpacity); + + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Position list when not enough above top item to fill viewport', + (WidgetTester tester) async { + const alignment = 0.8; + + await setUpWidgetTest( + tester, + itemCount: 2, + initialAlignment: alignment, + initialIndex: 0, + ); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')).dy, screenHeight * alignment); + }); + + testWidgets('Rebuild with scroll controller', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + 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'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Double rebuild with scroll controller', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final outerKey = ValueNotifier(ValueKey('outerKey')); + final innerKey = GlobalKey(); + final listKey = ValueNotifier(ValueKey(null)); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: outerKey, + builder: (context, outerKey, child) => ValueListenableBuilder( + valueListenable: listKey, + builder: (context, listKey, child) => Container( + key: outerKey, + child: Builder( + builder: (context) => Container( + key: innerKey, + child: ScrollablePositionedList.builder( + key: listKey, + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + outerKey.value = ValueKey('newOuterKey'); + listKey.value = ValueKey('newListKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 100'), findsOneWidget); + }); + + testWidgets('Key change with scroll controller', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) { + return ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + }); + + testWidgets('Scroll after rebuild', (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final key = ValueNotifier(ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + 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'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after rebuild when resusing state', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + final containerKey = ValueNotifier(ValueKey('key')); + final scrollKey = GlobalKey(); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: containerKey, + 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'), + ); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + containerKey.value = ValueKey('newKey'); + await tester.pumpAndSettle(); + + unawaited( + itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after changing scroll controller', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController0 = ItemScrollController(); + final itemScrollController1 = ItemScrollController(); + final itemScrollControllerListenable = ValueNotifier(itemScrollController0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, false); + + itemScrollControllerListenable.value = itemScrollController1; + await tester.pumpAndSettle(); + + expect(itemScrollController0.isAttached, false); + expect(itemScrollController1.isAttached, true); + + unawaited( + itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + }); + + testWidgets('Scroll after swapping scroll controllers', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController0 = ItemScrollController(); + final itemScrollController1 = ItemScrollController(); + final topItemScrollControllerListenable = + ValueNotifier(itemScrollController0); + final bottomItemScrollControllerListenable = + ValueNotifier(itemScrollController1); + + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: topItemScrollControllerListenable, + 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) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), + ), + ], + ), + )); + + await tester.pumpAndSettle(); + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, true); + + topItemScrollControllerListenable.value = itemScrollController1; + bottomItemScrollControllerListenable.value = itemScrollController0; + await tester.pumpAndSettle(); + + expect(itemScrollController0.isAttached, true); + expect(itemScrollController1.isAttached, true); + + unawaited( + itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + unawaited( + itemScrollController0.scrollTo(index: 50, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.text('Item 70'), findsOneWidget); + expect(find.text('Item 50'), findsOneWidget); + }); +} + +bool collectSemanticNodes(SemanticsNode root, List nodes) { + nodes.add(root); + if (!root.hasChildren) return true; + root.visitChildren((child) => collectSemanticNodes(child, nodes)); + return true; +} diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart new file mode 100644 index 00000000..5afdb438 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_positioned_list_test.dart @@ -0,0 +1,261 @@ +// 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/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const separatorHeight = screenHeight / 20.0; +const defaultItemCount = 500; +const cacheExtent = itemHeight * 2; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + ScrollController? scrollController, + double anchor = 0, + int itemCount = defaultItemCount, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: PositionedList( + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + cacheExtent: cacheExtent, + ), + ), + ); + } + + testWidgets('Empty list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 0); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Separator 0'), findsNothing); + }); + + testWidgets('Short list', (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 3); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + }); + + testWidgets('Short list centered at 1 scrolled up', + (WidgetTester tester) async { + await setUpWidgetTest(tester, itemCount: 3, topItem: 1); + + await tester.drag( + find.byType(PositionedList), const Offset(0, 2 * itemHeight)); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Separator 1'), findsOneWidget); + expect(find.text('Item 2'), findsOneWidget); + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + }); + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester); + await tester.pump(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + await tester.pump(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Separator 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + + expect(find.text('Separator 10'), findsOneWidget); + expect(find.text('Item 11'), findsOneWidget); + expect(find.text('Separator 11'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 11) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 20 at bottom', (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('Separator 19'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Separator 13'), findsOneWidget); + expect(find.text('Item 13'), findsOneWidget); + expect(find.text('Separator 12'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 19) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemLeadingEdge, + 1); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 13) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with item 20 at halfway', + (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 item 20 half off top of screen', + (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, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 20) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with 5 at top then scroll up 2 items', + (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 5); + + await tester.drag(find.byType(PositionedList), + const Offset(0, 2 * (itemHeight + separatorHeight))); + await tester.pump(); + + expect(find.text('Separator 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + _screenProportion(numberOfItems: -1, numberOfSeparators: -1)); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / + screenHeight; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..3a052d0b --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -0,0 +1,608 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const separatorHeight = screenHeight / 20.0; +const defaultItemCount = 500; +const scrollDuration = Duration(seconds: 1); +const scrollDurationTolerance = Duration(milliseconds: 1); +const tolerance = 1e-3; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + Key? key, + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + int initialIndex = 0, + double initialAlignment = 0.0, + int? itemCount, + ScrollPhysics? physics, + bool addSemanticIndexes = true, + int? semanticChildCount, + EdgeInsets? padding, + bool addRepaintBoundaries = true, + bool addAutomaticKeepAlives = true, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: itemCount ?? defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + key: key, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, + physics: physics, + addSemanticIndexes: addSemanticIndexes, + semanticChildCount: semanticChildCount, + padding: padding, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + ), + ), + ); + } + + testWidgets('List positioned with 0 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); + + testWidgets('List positioned with 0 at top - use default values', + (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: defaultItemCount, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + ); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); + + testWidgets('List positioned with 5 at top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, initialIndex: 5); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Separator 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Separator 10'), findsOneWidget); + expect(find.text('Item 11'), findsOneWidget); + expect(find.text('Separator 11'), findsNothing); + + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 4), + isEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + }); + + testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); + expect(tester.getTopLeft(find.text('Item 8')).dy, + screenHeight / 2 - itemHeight - separatorHeight); + expect(tester.getTopLeft(find.text('Item 10')).dy, + screenHeight / 2 + itemHeight + separatorHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + 0.5); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 8) + .itemLeadingEdge, + 0.5 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 10) + .itemLeadingEdge, + 0.5 + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + unawaited(itemScrollController.scrollTo( + index: 9, + duration: scrollDuration, + alignment: -(itemHeight / screenHeight) / 2)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('Jump to 9 half way off top', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController); + + itemScrollController.jumpTo( + index: 9, alignment: -(itemHeight / screenHeight) / 2); + await tester.pump(); + + expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 9) + .itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + }); + + testWidgets('List positioned with 9 at middle scroll to 16 at bottom', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5); + + unawaited(itemScrollController.scrollTo( + index: 16, duration: scrollDuration, alignment: 1.0)); + await tester.pump(); + await tester.pump(); + await tester.pump(scrollDuration + scrollDurationTolerance); + + expect(tester.getBottomRight(find.text('Item 15')).dy, + screenHeight - separatorHeight); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 15) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); + }); + + testWidgets('physics', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + physics: const BouncingScrollPhysics()); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + itemScrollController.jumpTo(index: 0); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); + + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + + testWidgets('correct index semantics', (WidgetTester tester) async { + await setUpWidgetTest(tester, initialIndex: 5); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 4 * itemHeight)); + await tester.pumpAndSettle(); + + final indexSemantics3 = tester.widget(find.ancestor( + of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics3.index, 3); + final indexSemantics4 = tester.widget(find.ancestor( + of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + expect(indexSemantics4.index, 4); + }); + + testWidgets('addIndexSemantics = false', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 5, + addSemanticIndexes: false, + ); + + expect(find.byType(IndexedSemantics), findsNothing); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + expect(find.byType(IndexedSemantics), findsNothing); + }); + + testWidgets('semanticChildCount specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + + await setUpWidgetTest( + tester, + semanticChildCount: 30, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, 30); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, 30); + }); + + testWidgets('semanticChildCount not specified', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + ); + + final customScrollView = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView.semanticChildCount, defaultItemCount); + + unawaited( + itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + await tester.pumpAndSettle(); + + final customScrollView2 = + tester.widget(find.byType(UnboundedCustomScrollView)); + expect(customScrollView2.semanticChildCount, defaultItemCount); + }); + + testWidgets('padding test - centered at top', (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, 10 + itemHeight + separatorHeight)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, 10 + itemHeight + separatorHeight)); + + unawaited( + itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, -500)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at top', + (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 + 2 * (separatorHeight + itemHeight))); + expect( + tester.getTopRight(find.text('Item 3')), + const Offset( + screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight))); + }); + + testWidgets('no repaint bounderies', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addRepaintBoundaries: false, + ); + + expect( + tester + .widgetList(find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(RepaintBoundary))) + .length, + lessThan(5)); + }); + + testWidgets('no automatic keep alives', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + initialIndex: 2, + padding: const EdgeInsets.all(10), + addAutomaticKeepAlives: false, + ); + + expect( + find.descendant( + of: find.byType(ScrollablePositionedList), + matching: find.byType(AutomaticKeepAlive)), + findsNothing); + }); + + testWidgets('List can be keyed', (WidgetTester tester) async { + final key = ValueKey('key'); + + await setUpWidgetTest(tester, key: key); + + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets('Empty list then update to single item list', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + initialScrollIndex: 0, + initialAlignment: 0, + 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'), + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + itemCount.value = 1; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 0'), findsNothing); + }); + + testWidgets('ItemPositions: Empty list then update to 10 items list', + (WidgetTester tester) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + final itemCount = ValueNotifier(0); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: itemCount, + builder: (context, itemCount, child) { + return ScrollablePositionedList.separated( + initialScrollIndex: 0, + initialAlignment: 0, + 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'), + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + expect(find.text('Separator 0'), findsNothing); + expect(itemPositionsListener.itemPositions.value, []); + + itemCount.value = 10; + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsOneWidget); + expect(find.text('Separator 5'), findsOneWidget); + expect(find.text('Item 6'), findsOneWidget); + expect(find.text('Separator 6'), findsNothing); + expect(find.text('Item 7'), findsNothing); + + expect(itemPositionsListener.itemPositions.value, isNotEmpty); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 6) + .itemTrailingEdge, + 1); + expect( + itemPositionsListener.itemPositions.value + .where((position) => position.index == 7), + isEmpty); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / + screenHeight; diff --git a/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..2c8dbacb --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -0,0 +1,220 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemWidth = screenWidth / 10.0; +const separatorWidth = screenWidth / 20.0; +const itemCount = 500; +const scrollDuration = Duration(seconds: 1); +const tolerance = 10e-5; + +void main() { + Future setUpWidgetTest( + WidgetTester tester, { + ItemScrollController? itemScrollController, + ItemPositionsListener? itemPositionsListener, + bool reverse = false, + EdgeInsets? padding, + int initialScrollIndex = 0, + }) async { + tester.binding.window.devicePixelRatioTestValue = 1.0; + tester.binding.window.physicalSizeTestValue = + const Size(screenWidth, screenHeight); + + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + width: itemWidth, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + width: separatorWidth, + child: Text('Separator $index'), + ), + itemPositionsListener: itemPositionsListener, + scrollDirection: Axis.horizontal, + reverse: reverse, + padding: padding, + initialScrollIndex: initialScrollIndex, + ), + ), + ); + } + + testWidgets('List positioned with 0 at left', (WidgetTester tester) async { + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); + + expect(tester.getTopLeft(find.text('Item 0')).dx, 0); + expect(tester.getBottomLeft(find.text('Item 1')).dx, + itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 0) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 1) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest(tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener); + + 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')).dx, 0); + expect( + tester.getTopLeft(find.text('Item 3')).dx, itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Scroll to 100 (not already on screen)', + (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 == 101) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('Jump to 100', (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')).dx, 0); + expect(tester.getTopLeft(find.text('Item 101')).dx, + itemWidth + separatorWidth); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 100) + .itemLeadingEdge, + 0); + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 101) + .itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + }); + + testWidgets('padding test - centered sliver at left', + (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 + itemWidth + separatorWidth, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + 2 * itemWidth + separatorWidth, screenHeight - 10)); + + unawaited( + itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expect(tester.getBottomRight(find.text('Item 499')), + const Offset(screenWidth - 10, screenHeight - 10)); + }); + + testWidgets('padding test - centered sliver not at left', + (WidgetTester tester) async { + final itemScrollController = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialScrollIndex: 2, + padding: const EdgeInsets.all(10), + ); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(300, 0)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); + expect(tester.getTopLeft(find.text('Item 2')), + const Offset(10 + 2 * (itemWidth + separatorWidth), 10)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10 + 3 * (itemWidth + separatorWidth), 10)); + + expect( + itemPositionsListener.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + closeTo( + 10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth), + tolerance)); + }); +} + +double _screenProportion( + {required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemWidth + numberOfSeparators * separatorWidth) / + screenHeight;