diff --git a/analysis_options.yaml b/analysis_options.yaml index a1fa9388..e5eaf7ca 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,7 @@ analyzer: exclude: - packages/*/lib/**/*.g.dart - packages/*/lib/src/emoji/** + - packages/*/lib/scrollable_positioned_list/** - packages/*/lib/**/*.freezed.dart linter: diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e0a43071..ca22b598 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,8 @@ - Fixed message highlight animation alignment in `MessageListView` - [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order. +- Fixed `MessageListView` initialIndex not working in some cases. +- Improved `MessageListView` rendering in case of reordering. - Fix image thumbnail generation when using Stream CDN ✅ Added diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/LICENSE b/packages/stream_chat_flutter/lib/scrollable_positioned_list/LICENSE new file mode 100644 index 00000000..7cc8ade1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/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/scrollable_positioned_list/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/scrollable_positioned_list.dart new file mode 100644 index 00000000..c275b611 --- /dev/null +++ b/packages/stream_chat_flutter/lib/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/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart new file mode 100644 index 00000000..218e2ca0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart @@ -0,0 +1,98 @@ +// 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 { + const _InheritedRegistryWidget({ + Key? key, + required this.state, + required Widget child, + }) : super(key: key, child: child); + + final _RegistryWidgetState state; + + @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/scrollable_positioned_list/src/indexed_key.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/indexed_key.dart new file mode 100644 index 00000000..bbff45c2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/indexed_key.dart @@ -0,0 +1,31 @@ +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart'; + +/// {@template indexed_key} +/// Creates an indexed key that delegates its [operator==] to the given key. +/// +/// It contains an index used in [ScrollablePositionedList]. +/// {@endtemplate} +class IndexedKey extends LocalKey { + /// {@macro indexed_key} + 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() => '(IndexedKey) index: $index, key: $key'; +} diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart new file mode 100644 index 00000000..1b611393 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart @@ -0,0 +1,62 @@ +// 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:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/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 * (index.hashCode + 7) + itemLeadingEdge.hashCode) + + itemTrailingEdge.hashCode; + + @override + String toString() => + '''ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'''; +} diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_notifier.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_notifier.dart new file mode 100644 index 00000000..3b3645cb --- /dev/null +++ b/packages/stream_chat_flutter/lib/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 'package:stream_chat_flutter/scrollable_positioned_list/src/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/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart new file mode 100644 index 00000000..c2c2a278 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -0,0 +1,370 @@ +// 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 'package:stream_chat_flutter/scrollable_positioned_list/src/element_registry.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/indexed_key.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/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({ + Key? key, + 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((positionedIndex == 0) || (positionedIndex < itemCount), + 'positionedIndex cannot be 0 and must be smaller than itemCount'), + 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; + + /// 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( + widget.positionedIndex * 2 - (index + 1), + ), + childCount: widget.separatorBuilder == null + ? widget.positionedIndex + : widget.positionedIndex * 2, + 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 + widget.positionedIndex * 2, + ), + 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 + widget.positionedIndex * 2 + 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)) ?? + const 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, + ) ?? + const EdgeInsets.all(0) + : widget.padding?.copyWith( + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.bottom + : 0, + ) ?? + const 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, + ) ?? + const EdgeInsets.all(0) + : widget.padding?.copyWith( + left: widget.positionedIndex == 0 ? widget.padding!.left : 0, + right: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.right + : 0, + ) ?? + const EdgeInsets.all(0); + + EdgeInsets get _trailingSliverPadding => + widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(bottom: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(top: 0) ?? const EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith(right: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(left: 0) ?? const EdgeInsets.all(0); + + void _schedulePositionNotificationUpdate() { + if (!updateScheduled) { + updateScheduled = true; + SchedulerBinding.instance!.addPostFrameCallback((_) { + if (registeredElements.value == null) { + updateScheduled = false; + return; + } + final positions = []; + RenderViewport? viewport; + for (final element in registeredElements.value!) { + final box = element.renderObject as RenderBox?; + viewport ??= RenderAbstractViewport.of(box) as RenderViewport?; + if (viewport == null || box == null) { + continue; + } + final key = element.widget.key as IndexedKey; + if (widget.scrollDirection == Axis.vertical) { + final reveal = viewport.getOffsetToReveal(box, 0).offset; + if (!reveal.isFinite) continue; + 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/scrollable_positioned_list/src/post_mount_callback.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/post_mount_callback.dart new file mode 100644 index 00000000..ddb3e3d4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/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 = widget as PostMountCallback; + postMountCallback.callback?.call(); + } +} diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart new file mode 100644 index 00000000..a0b499c0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -0,0 +1,79 @@ +// 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 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart'; + +/// {@template custom_scroll_view} +/// A version of [CustomScrollView] that does not constrict the extents +/// to be within 0 and 1. See [CustomScrollView] for more information. +/// {@endtemplate} +class UnboundedCustomScrollView extends CustomScrollView { + /// {@macro custom_scroll_view} + 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/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart new file mode 100644 index 00000000..765a90a0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -0,0 +1,593 @@ +// 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 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/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, + }) : 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(separatorBuilder != null, 'seperatorBuilder cannot be 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, '_scrollableListState cannot be null'); + assert(opacityAnimationWeights.length == 3, + 'opacityAnimationWeights.length is not equal to 3'); + assert(duration > Duration.zero, + 'duration needs to be bigger than Duration.zero'); + return _scrollableListState!._scrollTo( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + + void _attach(_ScrollablePositionedListState scrollableListState) { + assert( + _scrollableListState == null, '_scrollableListState needs to be null'); + _scrollableListState = scrollableListState; + } + + void _detach() { + _scrollableListState = null; + } +} + +class _ScrollablePositionedListState extends State + with TickerProviderStateMixin { + /// Details for the primary (active) [ListView]. + _ListDisplayDetails primary = _ListDisplayDetails(const ValueKey('Ping')); + + /// Details for the secondary (transitional) [ListView] that is temporarily + /// shown when scrolling a long distance. + _ListDisplayDetails secondary = _ListDisplayDetails(const ValueKey('Pong')); + + final opacity = ProxyAnimation(const AlwaysStoppedAnimation(0)); + + void Function() startAnimationCallback = () {}; + + bool _isTransitioning = false; + + @override + void initState() { + super.initState(); + final ItemPosition? initialPosition = + PageStorage.of(context)!.readState(context); + primary + ..target = initialPosition?.index ?? widget.initialScrollIndex + ..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) { + primary.target = 0; + secondary.target = 0; + } else { + if (primary.target > widget.itemCount - 1) { + primary.target = widget.itemCount - 1; + } + if (secondary.target > widget.itemCount - 1) { + secondary.target = widget.itemCount - 1; + } + } + } + + @override + Widget build(BuildContext context) => 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 + ..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 + ..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. + final temp = primary; + primary = secondary; + secondary = temp; + } + _isTransitioning = false; + opacity.parent = const AlwaysStoppedAnimation(0); + }); + } + + Animatable _opacityAnimation(List opacityAnimationWeights) { + const startOpacity = 0.0; + const 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/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart new file mode 100644 index 00000000..01dce022 --- /dev/null +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart @@ -0,0 +1,326 @@ +// 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. + +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// {@template unbounded_viewport} +/// 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. +/// description +class UnboundedViewport extends Viewport { + /// {@macro unbounded_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) => + 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) { + 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) { + 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, 'firstChild cannot be null'); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0, 0); + return; + } + assert(center!.parent == this, 'center.parent cannot be equal to 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 { + 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, 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, 'count not equal to 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; + }(), 'count needs to be bigger than _maxLayoutCycles'); + } + + double _attemptLayout( + double mainAxisExtent, + double crossAxisExtent, + double correctedOffset, + ) { + assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN'); + assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0'); + assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite'); + assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0'); + assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite'); + _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 centerOffset = mainAxisExtent * anchor - correctedOffset; + final reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: (mainAxisExtent - centerOffset) + .clamp(-_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) return -result; + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, + layoutOffset: centerOffset >= mainAxisExtent + ? centerOffset + : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 67c7dd2b..41b8d039 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -97,7 +97,7 @@ class ImageAttachment extends AttachmentWidget { return _buildImageAttachment( context, CachedNetworkImage( - cacheKey: imageUrl, + cacheKey: imageUri.replace(queryParameters: {}).toString(), height: size?.height, width: size?.width, placeholder: (context, __) { 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 3d0cbbb7..47b47785 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -6,8 +6,7 @@ import 'package:flutter/cupertino.dart'; 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/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'; @@ -145,7 +144,8 @@ class MessageListView extends StatefulWidget { this.threadBuilder, this.onThreadTap, this.dateDividerBuilder, - this.scrollPhysics = const ClampingScrollPhysics(), + this.scrollPhysics = + const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading this.initialScrollIndex, this.initialAlignment, this.scrollController, @@ -225,7 +225,7 @@ class MessageListView extends StatefulWidget { final ItemPositionsListener? itemPositionListener; /// The ScrollPhysics used by the ListView - final ScrollPhysics scrollPhysics; + final ScrollPhysics? scrollPhysics; /// Called when message item gets swiped final OnMessageSwiped? onMessageSwiped; @@ -296,7 +296,7 @@ class MessageListView extends StatefulWidget { class _MessageListViewState extends State { ItemScrollController? _scrollController; void Function(Message)? _onThreadTap; - bool _showScrollToBottom = false; + final ValueNotifier _showScrollToBottom = ValueNotifier(false); late final ItemPositionsListener _itemPositionListener; int? _messageListLength; StreamChannelState? streamChannel; @@ -306,12 +306,17 @@ class _MessageListViewState extends State { final initialScrollIndex = widget.initialScrollIndex; if (initialScrollIndex != null) return initialScrollIndex; if (streamChannel!.initialMessageId != null) { - final messages = streamChannel!.channel.state!.messages; + final messages = streamChannel!.channel.state!.messages + .where(widget.messageFilter ?? + defaultMessageFilter( + streamChannel!.channel.client.state.currentUser!.id, + )) + .toList(growable: false); final totalMessages = messages.length; final messageIndex = messages.indexWhere((e) => e.id == streamChannel!.initialMessageId); final index = totalMessages - messageIndex; - if (index != 0) return index - 1; + if (index != 0) return index + 1; return index; } return 0; @@ -320,7 +325,7 @@ class _MessageListViewState extends State { double get _initialAlignment { final initialAlignment = widget.initialAlignment; if (initialAlignment != null) return initialAlignment; - return 0; + return 0.1; } bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id; @@ -329,7 +334,6 @@ class _MessageListViewState extends State { bool get _isThreadConversation => widget.parentMessage != null; - bool _topPaginationActive = false; bool _bottomPaginationActive = false; int initialIndex = 0; @@ -337,6 +341,8 @@ class _MessageListViewState extends State { List messages = []; + Map messagesIndex = {}; + bool initialMessageHighlightComplete = false; bool _inBetweenList = false; @@ -382,6 +388,9 @@ class _MessageListViewState extends State { Widget _buildListView(List data) { messages = data; + for (var index = 0; index < messages.length; index++) { + messagesIndex[messages[index].id] = index; + } final newMessagesListLength = messages.length; if (_messageListLength != null) { @@ -394,10 +403,6 @@ class _MessageListViewState extends State { initialAlignment = first.itemLeadingEdge; } } - } else if (!_topPaginationActive && _upToDate) { - // Reset the index in-case we send any new message - initialIndex = 0; - initialAlignment = 0; } } @@ -441,7 +446,6 @@ class _MessageListViewState extends State { onStartOfPage: () async { _inBetweenList = false; if (!_upToDate) { - _topPaginationActive = false; _bottomPaginationActive = true; return _paginateData( streamChannel, @@ -451,7 +455,6 @@ class _MessageListViewState extends State { }, onEndOfPage: () async { _inBetweenList = false; - _topPaginationActive = true; _bottomPaginationActive = false; return _paginateData( streamChannel, @@ -462,17 +465,26 @@ class _MessageListViewState extends State { _inBetweenList = true; }, child: ScrollablePositionedList.separated( - key: _upToDate - ? null - : ValueKey(initialIndex + initialAlignment), + key: (initialIndex != 0 && initialAlignment != 0) + ? ValueKey('$initialIndex-$initialAlignment') + : null, itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, initialAlignment: initialAlignment, physics: widget.scrollPhysics, itemScrollController: _scrollController, reverse: widget.reverse, - addAutomaticKeepAlives: false, itemCount: itemCount, + findChildIndexCallback: (Key key) { + final indexedKey = key as IndexedKey; + final valueKey = indexedKey.key as ValueKey?; + if (valueKey != null) { + final index = messagesIndex[valueKey.value]; + if (index != null) { + return ((index + 2) * 2) - 1; + } + } + }, // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| @@ -624,14 +636,30 @@ class _MessageListViewState extends State { } else { messageWidget = buildMessage(message, messages, i - 2); } - return messageWidget; + return KeyedSubtree( + key: ValueKey(message.id), + child: messageWidget, + ); }, ), ), ); }, ), - if (widget.showScrollToBottom) _buildScrollToBottom(), + BetterStreamBuilder( + stream: streamChannel!.channel.state!.isUpToDateStream, + initialData: streamChannel!.channel.state!.isUpToDate, + builder: (context, snapshot) => ValueListenableBuilder( + valueListenable: _showScrollToBottom, + child: _buildScrollToBottom(), + builder: (context, value, child) { + if (!snapshot || value) { + return child!; + } + return const Offstage(); + }, + ), + ), if (widget.showFloatingDateDivider) _buildFloatingDateDivider(itemCount), ], @@ -751,24 +779,15 @@ class _MessageListViewState extends State { .index; } - Widget _buildScrollToBottom() => StreamBuilder>( - stream: Rx.combineLatest2( - streamChannel!.channel.state!.isUpToDateStream.distinct(), - streamChannel!.channel.state!.unreadCountStream.distinct(), - (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), - ), + Widget _buildScrollToBottom() => StreamBuilder( + stream: streamChannel!.channel.state!.unreadCountStream, builder: (_, snapshot) { if (snapshot.hasError) { return const Offstage(); } else if (!snapshot.hasData) { return const Offstage(); } - final isUpToDate = snapshot.data!.item1; - final showScrollToBottom = !isUpToDate || _showScrollToBottom; - if (!showScrollToBottom) { - return const Offstage(); - } - final unreadCount = snapshot.data!.item2; + final unreadCount = snapshot.data!; final showUnreadCount = unreadCount > 0 && streamChannel!.channel.state!.members.any((e) => e.userId == @@ -783,16 +802,21 @@ class _MessageListViewState extends State { children: [ FloatingActionButton( backgroundColor: _streamTheme.colorTheme.barsBg, - onPressed: () { + onPressed: () async { if (unreadCount > 0) { streamChannel!.channel.markRead(); } if (!_upToDate) { _bottomPaginationActive = false; - _topPaginationActive = false; - streamChannel!.reloadChannel(); + initialAlignment = 0; + initialIndex = 0; + await streamChannel!.reloadChannel(); + + WidgetsBinding.instance?.addPostFrameCallback((_) { + _scrollController!.jumpTo(index: 0); + }); } else { - setState(() => _showScrollToBottom = false); + _showScrollToBottom.value = false; _scrollController!.scrollTo( index: 0, duration: const Duration(seconds: 1), @@ -854,9 +878,8 @@ class _MessageListViewState extends State { int index, ) { final messageWidget = buildMessage(message, messages, index); - return VisibilityDetector( - key: ValueKey('BOTTOM-MESSAGE-${message.id}'), + key: ValueKey('visibility: ${message.id}'), onVisibilityChanged: (visibility) { final isVisible = visibility.visibleBounds != Rect.zero; if (isVisible) { @@ -868,8 +891,8 @@ class _MessageListViewState extends State { } } if (mounted) { - if (_showScrollToBottom == isVisible) { - setState(() => _showScrollToBottom = !isVisible); + if (_showScrollToBottom.value == isVisible) { + _showScrollToBottom.value = !isVisible; } } }, @@ -948,16 +971,11 @@ class _MessageListViewState extends State { return defaultMessageWidget; } - Widget buildMessage( - Message message, - List messages, - int index, - ) { + Widget buildMessage(Message message, List messages, int index) { if ((message.type == 'system' || message.type == 'error') && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? SystemMessage( - key: ValueKey('MESSAGE-${message.id}'), message: message, onMessageTap: (message) { if (widget.onSystemMessageTap != null) { @@ -1037,7 +1055,6 @@ class _MessageListViewState extends State { members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); Widget messageWidget = MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), message: message, reverse: isMyMessage, showReactions: !message.isDeleted, @@ -1049,23 +1066,20 @@ class _MessageListViewState extends State { showSendingIndicator: showSendingIndicator, showUserAvatar: showUserAvatar, onQuotedMessageTap: (quotedMessageId) async { - // ignore: prefer_function_declarations_over_variables - final scrollToIndex = () { + if (messages.map((e) => e.id).contains(quotedMessageId)) { final index = messages.indexWhere((m) => m.id == quotedMessageId); _scrollController?.scrollTo( - index: index, - duration: const Duration(milliseconds: 350), + index: index + 2, // +2 to account for loader and footer + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + alignment: 0.1, ); - }; - if (messages.map((e) => e.id).contains(quotedMessageId)) { - scrollToIndex(); } else { - await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) { - WidgetsBinding.instance!.addPostFrameCallback((_) { - if (messages.map((e) => e.id).contains(quotedMessageId)) { - scrollToIndex(); - } - }); + await streamChannel! + .loadChannelAtMessage(quotedMessageId) + .then((_) async { + initialIndex = 21; // 19 + 2 | 19 is the index of the message + initialAlignment = 0.1; }); } }, @@ -1229,20 +1243,17 @@ class _MessageListViewState extends State { initialIndex = _initialIndex; initialAlignment = _initialAlignment; - WidgetsBinding.instance!.addPostFrameCallback((timeStamp) { - if (_scrollController?.isAttached == true) { - _scrollController?.jumpTo( - index: initialIndex, - alignment: initialAlignment, - ); - } - }); + if (_scrollController?.isAttached == true) { + _scrollController?.jumpTo( + index: initialIndex, + alignment: initialAlignment, + ); + } _messageNewListener = streamChannel!.channel.on(EventType.messageNew).listen((event) { if (_upToDate) { _bottomPaginationActive = false; - _topPaginationActive = false; } if (event.message?.parentId == widget.parentMessage?.id && event.message!.user!.id == diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 5ba78a26..f6ae52c6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -608,6 +608,8 @@ class _MessageWidgetState extends State final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; + final showReactions = _shouldShowReactions; + return Material( type: widget.message.pinned && widget.showPinHighlight ? MaterialType.card @@ -671,17 +673,23 @@ class _MessageWidgetState extends State SizedBox(width: avatarWidth + 4), Flexible( child: PortalEntry( - portal: Container( - transform: Matrix4.translationValues( - widget.reverse ? 12 : -12, - 0, - 0, - ), - constraints: const BoxConstraints( - maxWidth: 22 * 6.0, - ), - child: _buildReactionIndicator(context), - ), + visible: showReactions, + portal: showReactions + ? Container( + transform: + Matrix4.translationValues( + widget.reverse ? 12 : -12, + 0, + 0, + ), + constraints: const BoxConstraints( + maxWidth: 22 * 6.0, + ), + child: _buildReactionIndicator( + context, + ), + ) + : null, portalAnchor: Alignment(widget.reverse ? 1 : -1, -1), childAnchor: @@ -1036,9 +1044,7 @@ class _MessageWidgetState extends State return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: (widget.showReactions && - (widget.message.reactionCounts?.isNotEmpty == true) && - !widget.message.isDeleted) + child: _shouldShowReactions ? GestureDetector( onTap: () => _showMessageReactionsModalBottomSheet(context), child: ReactionBubble( @@ -1058,6 +1064,11 @@ class _MessageWidgetState extends State ); } + bool get _shouldShowReactions => + widget.showReactions && + (widget.message.reactionCounts?.isNotEmpty == true) && + !widget.message.isDeleted; + void _showMessageActionModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index a36baba9..249656b2 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -34,7 +34,6 @@ dependencies: photo_manager: ^1.2.6+1 photo_view: ^0.12.0 rxdart: ^0.27.0 - scrollable_positioned_list: ^0.2.0-nullsafety.0 share_plus: ^2.0.3 shimmer: ^2.0.0 stream_chat_flutter_core: ^3.1.1 diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..7c264e39 --- /dev/null +++ b/packages/stream_chat_flutter/test/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/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(itemWidth + 10, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + itemWidth * 2, 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 + itemWidth * 2, 10)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10 + itemWidth * 3, 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 - (itemWidth + 10), 10)); + expect(tester.getBottomLeft(find.text('Item 1')), + const Offset(screenWidth - (10 + itemWidth * 2), 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 + itemWidth * 2), 10)); + expect(tester.getTopRight(find.text('Item 3')), + const Offset(screenWidth - (10 + itemWidth * 3), 10)); + }); +} diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart new file mode 100644 index 00000000..9888249c --- /dev/null +++ b/packages/stream_chat_flutter/test/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/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; +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, itemHeight * 2)); + await tester.pump(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 12'), findsOneWidget); + expect(find.text('Item 13'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 2) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 3) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 12) + .itemTrailingEdge, + 1); + }); + + testWidgets('List positioned with 5 at top then scroll down 1/2', + (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(itemHeight * 5); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + }); + + testWidgets('List positioned with 5 at top then scroll up 2 programatically', + (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(itemHeight * 20); + await tester.pump(); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 23) + .itemLeadingEdge, + -2 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 24) + .itemLeadingEdge, + -1 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 25) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -21 / 10); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + -20 / 10); + }); + + testWidgets('List positioned with 5 at top and initial scroll offset', + (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/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart new file mode 100644 index 00000000..272e2459 --- /dev/null +++ b/packages/stream_chat_flutter/test/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/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const defaultItemCount = 500; + +void main() { + final itemPositionsNotifier = ItemPositionsListener.create(); + + Future setUpWidgetTest( + WidgetTester tester, { + int topItem = 0, + 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); + 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, itemHeight * 2)); + 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(itemHeight * 5); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.text('Item 14'), findsOneWidget); + expect(find.text('Item 15'), findsNothing); + + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 5) + .itemLeadingEdge, + 0); + expect( + itemPositionsNotifier.itemPositions.value + .firstWhere((position) => position.index == 4) + .itemLeadingEdge, + -1 / 10); + }); + + testWidgets( + 'List positioned with 5 at bottom then scroll up 2 programatically', + (WidgetTester tester) async { + final scrollController = ScrollController(); + await setUpWidgetTest(tester, + topItem: 5, scrollController: scrollController); + + scrollController.jumpTo(itemHeight * 2); + 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: itemHeight * 2); + 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/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..f177b7db --- /dev/null +++ b/packages/stream_chat_flutter/test/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/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 - (itemHeight + 10))); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, screenHeight - (10 + itemHeight * 2))); + + unawaited( + itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 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 + itemHeight * 2))); + expect(tester.getBottomLeft(find.text('Item 3')), + const Offset(10, screenHeight - (10 + itemHeight * 3))); + }); +} diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart new file mode 100644 index 00000000..ae0e44fa --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -0,0 +1,2274 @@ +// 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/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/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, + '''index needs to be bigger or equal to 0 and smallert than 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)); + 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); + + final 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); + 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, 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); + + final 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(); + + final 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, itemHeight * 2)); + 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, itemHeight * 2)); + 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, itemHeight + 10)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, itemHeight + 10)); + + 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 + itemHeight * 2)); + expect(tester.getTopLeft(find.text('Item 3')), + const Offset(10, 10 + itemHeight * 3)); + }); + + 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 { + const 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: const PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + const 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: const PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + const 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: const PageStorageKey('key'), + child: ScrollablePositionedList.builder( + itemCount: defaultItemCount, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + ), + ), + const 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) => + ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount), + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount - 1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ), + ), + ); + + 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) => + ScrollablePositionedList.builder( + initialScrollIndex: min(100, itemCount - 1), + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount -1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ), + ), + ); + + 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) => + ScrollablePositionedList.builder( + initialScrollIndex: itemCount - 1, + itemCount: itemCount, + itemBuilder: (context, index) { + assert(index >= 0 && index <= itemCount - 1, + 'index not bigger than 0 and smaller than itemCount -1'); + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ), + ), + ), + ); + + 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: itemHeight * 200); + + 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: itemHeight * 200, + ); + + 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, + ); + + 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(const ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 200, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = const 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(const ValueKey('outerKey')); + final innerKey = GlobalKey(); + final listKey = ValueNotifier(const 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 = const ValueKey('newOuterKey'); + listKey.value = const 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(const ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) => ScrollablePositionedList.builder( + key: key, + itemCount: 10, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = const 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(const ValueKey('key')); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: key, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + key.value = const 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(const ValueKey('key')); + final scrollKey = GlobalKey(); + final itemScrollController = ItemScrollController(); + + await tester.pumpWidget( + MaterialApp( + home: ValueListenableBuilder( + valueListenable: containerKey, + builder: (context, key, child) => Container( + key: key, + child: ScrollablePositionedList.builder( + key: scrollKey, + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + containerKey.value = const 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) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => 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) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: bottomItemScrollControllerListenable, + builder: (context, itemScrollController, child) => + ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + ), + ), + ), + ], + ), + )); + + 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/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart new file mode 100644 index 00000000..47f75a5e --- /dev/null +++ b/packages/stream_chat_flutter/test/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/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const 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, itemHeight * 2)); + 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/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..2242c533 --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -0,0 +1,602 @@ +// 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/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/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)); + 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, itemHeight * 4)); + 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, itemHeight + 10 + separatorHeight)); + expect(tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, itemHeight + 10 + 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 { + const 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) => + ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ), + ), + ), + ); + + 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) => + ScrollablePositionedList.separated( + itemCount: itemCount, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + separatorBuilder: (context, index) => SizedBox( + height: separatorHeight, + child: Text('Separator $index'), + ), + ), + ), + ), + ); + + 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/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart new file mode 100644 index 00000000..4057cab8 --- /dev/null +++ b/packages/stream_chat_flutter/test/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/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(itemWidth + 10 + separatorWidth, 10)); + expect(tester.getBottomRight(find.text('Item 1')), + const Offset(10 + itemWidth * 2 + 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; diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index b9b423ce..74e40d7f 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -9,6 +9,14 @@ import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; +/// Default filter for the message list +bool Function(Message) defaultMessageFilter(String currentUserId) => + (Message m) { + final isMyMessage = m.user?.id == currentUserId; + if (m.shadowed && !isMyMessage) return false; + return true; + }; + /// [MessageListCore] is a simplified class that allows fetching a list of /// messages while exposing UI builders. /// @@ -132,25 +140,20 @@ class MessageListCoreState extends State { ? _streamChannel!.channel.state?.threads[widget.parentMessage!.id] : _streamChannel!.channel.state?.messages; - bool defaultFilter(Message m) { - final isMyMessage = m.user?.id == _currentUser?.id; - if (m.shadowed && !isMyMessage) return false; - return true; - } - return BetterStreamBuilder>( initialData: initialData, comparator: const ListEquality().equals, - stream: messagesStream!.map( - (messages) => - messages?.where(widget.messageFilter ?? defaultFilter).toList( - growable: false, - ), - ), + stream: messagesStream, errorBuilder: widget.errorBuilder, noDataBuilder: widget.loadingBuilder, builder: (context, data) { - final messageList = data.reversed.toList(growable: false); + final messageList = data + .where( + widget.messageFilter ?? defaultMessageFilter(_currentUser!.id), + ) + .toList(growable: false) + .reversed + .toList(growable: false); if (messageList.isEmpty && !_isThreadConversation) { if (_upToDate) { return widget.emptyBuilder(context);