feat: move scrollable positioned list to internal

src
This commit is contained in:
Gordon Hayes
2021-10-15 15:13:59 +02:00
parent b4e544930e
commit 3a7343904d
22 changed files with 6223 additions and 6 deletions
@@ -7,10 +7,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/info_tile.dart';
import 'package:stream_chat_flutter/src/message_widget.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/swipeable.dart';
import 'package:stream_chat_flutter/src/system_message.dart';
@@ -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.
@@ -0,0 +1,53 @@
# scrollable_positioned_list
A flutter list that allows scrolling to a specific item in the list.
Also allows determining what items are currently visible.
## Usage
A `ScrollablePositionedList` works much like the builder version of `ListView`
except that the list can be scrolled or jumped to a specific item.
### Example
A `ScrollablePositionedList` can be created with:
```dart
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
ScrollablePositionedList.builder(
itemCount: 500,
itemBuilder: (context, index) => Text('Item $index'),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
);
```
One then can scroll to a particular item with:
```dart
itemScrollController.scrollTo(
index: 150,
duration: Duration(seconds: 2),
curve: Curves.easeInOutCubic);
```
or jump to a particular item with:
```dart
itemScrollController.jumpTo(index: 150);
```
One can monitor what items are visible on screen with:
```dart
itemPositionsListener.itemPositions.addListener(() => ...);
```
A full example can be found in the example folder.
--------------------------------------------------------------------------------
This is not an officially supported Google product.
@@ -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';
@@ -0,0 +1,96 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// A registry to track some [Element]s in the tree.
class RegistryWidget extends StatefulWidget {
/// Creates a [RegistryWidget].
const RegistryWidget({Key? key, this.elementNotifier, required this.child})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Contains the current set of all [Element]s created by
/// [RegisteredElementWidget]s in the tree below this widget.
///
/// Note that if there is another [RegistryWidget] in this widget's subtree
/// that registry, and not this one, will collect elements in its subtree.
final ValueNotifier<Set<Element>?>? elementNotifier;
@override
State<StatefulWidget> 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<RegistryWidget> {
final Set<Element> registeredElements = {};
@override
Widget build(BuildContext context) => _InheritedRegistryWidget(
state: this,
child: widget.child,
);
}
class _InheritedRegistryWidget extends InheritedWidget {
final _RegistryWidgetState state;
const _InheritedRegistryWidget(
{Key? key, required this.state, required Widget child})
: super(key: key, child: child);
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}
class _RegisteredElement extends ProxyElement {
_RegisteredElement(ProxyWidget widget) : super(widget);
@override
void notifyClients(ProxyWidget oldWidget) {}
late _RegistryWidgetState _registryWidgetState;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void unmount() {
_registryWidgetState.registeredElements.remove(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
super.unmount();
}
}
@@ -0,0 +1,30 @@
import 'dart:ui' show hashValues;
import 'package:flutter/foundation.dart';
class IndexedKey extends LocalKey {
/// Creates an indexed key that delegates its [operator==] to the given key.
///
/// It contains an index used in [ScrollablePositionedList].
const IndexedKey(this.key, this.index);
/// The key to which this this delegates its [operator==].
final Key? key;
/// Index used to show position in a list.
final int index;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is IndexedKey && other.key == key;
}
@override
int get hashCode => hashValues(runtimeType, key);
@override
String toString() {
return '(IndexedKey) index: $index, key: $key';
}
}
@@ -0,0 +1,61 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_notifier.dart';
import 'scrollable_positioned_list.dart';
/// Provides a listenable iterable of [itemPositions] of items that are on
/// screen and their locations.
abstract class ItemPositionsListener {
/// Creates an [ItemPositionsListener] that can be used by a
/// [ScrollablePositionedList] to return the current position of items.
factory ItemPositionsListener.create() => ItemPositionsNotifier();
/// The position of items that are at least partially visible in the viewport.
ValueListenable<Iterable<ItemPosition>> get itemPositions;
}
/// Position information for an item in the list.
class ItemPosition {
/// Create an [ItemPosition].
const ItemPosition(
{required this.index,
required this.itemLeadingEdge,
required this.itemTrailingEdge});
/// Index of the item.
final int index;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the leading edge of the item.
///
/// May be negative if the item is partially visible.
final double itemLeadingEdge;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the trailing edge of the item.
///
/// May be greater than one if the item is partially visible.
final double itemTrailingEdge;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) return false;
final ItemPosition otherPosition = other;
return otherPosition.index == index &&
otherPosition.itemLeadingEdge == itemLeadingEdge &&
otherPosition.itemTrailingEdge == itemTrailingEdge;
}
@override
int get hashCode =>
31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) +
itemTrailingEdge.hashCode;
@override
String toString() =>
'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)';
}
@@ -0,0 +1,13 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_listener.dart';
/// Internal implementation of [ItemPositionsListener].
class ItemPositionsNotifier implements ItemPositionsListener {
@override
final ValueNotifier<Iterable<ItemPosition>> itemPositions = ValueNotifier([]);
}
@@ -0,0 +1,359 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'element_registry.dart';
import 'indexed_key.dart';
import 'item_positions_listener.dart';
import 'item_positions_notifier.dart';
import 'scroll_view.dart';
/// A list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [PositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [positionIndex] positioned at a
/// particular [alignment]. See [ItemScrollController.jumpTo] for an
/// explanation of alignment.
///
/// All other parameters are the same as specified in [ListView].
class PositionedList extends StatefulWidget {
/// Create a [PositionedList].
const PositionedList({
required this.itemCount,
required this.itemBuilder,
this.separatorBuilder,
this.controller,
this.itemPositionsNotifier,
this.positionedIndex = 0,
this.alignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.padding,
this.cacheExtent,
this.semanticChildCount,
this.findChildIndexCallback,
this.addSemanticIndexes = true,
this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert((positionedIndex == 0) || (positionedIndex < itemCount));
/// Called to find the new index of a child based on its key in case of reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// If not null, called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
final ScrollController? controller;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
/// Index of an item to initially align to a position within the viewport
/// defined by [alignment].
final int positionedIndex;
/// Determines where the leading edge of the item at [positionedIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double alignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// {@macro flutter.widgets.scrollable.cacheExtent}
final double? cacheExtent;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
@override
State<StatefulWidget> createState() => _PositionedListState();
}
class _PositionedListState extends State<PositionedList> {
final Key _centerKey = UniqueKey();
final registeredElements = ValueNotifier<Set<Element>?>(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: <Widget>[
if (widget.positionedIndex > 0)
SliverPadding(
padding: _leadingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(widget.positionedIndex - (index + 1))
: _buildSeparatedListElement(
2 * widget.positionedIndex - (index + 1)),
childCount: widget.separatorBuilder == null
? widget.positionedIndex
: 2 * widget.positionedIndex,
addSemanticIndexes: false,
findChildIndexCallback: widget.findChildIndexCallback,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
SliverPadding(
key: _centerKey,
padding: _centerSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex),
childCount: widget.itemCount != 0 ? 1 : 0,
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
if (widget.positionedIndex >= 0 &&
widget.positionedIndex < widget.itemCount - 1)
SliverPadding(
padding: _trailingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex + 1)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex + 1),
childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1),
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
],
),
);
Widget _buildSeparatedListElement(int index) {
if (index.isEven) {
return _buildItem(index ~/ 2);
} else {
return widget.separatorBuilder!(context, index ~/ 2);
}
}
Widget _buildItem(int index) {
final child = widget.itemBuilder(context, index);
return RegisteredElementWidget(
key: IndexedKey(child.key, index),
child: widget.addSemanticIndexes
? IndexedSemantics(index: index, child: child)
: child,
);
}
EdgeInsets get _leadingSliverPadding =>
(widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(top: 0)
: widget.padding?.copyWith(bottom: 0)
: widget.reverse
? widget.padding?.copyWith(left: 0)
: widget.padding?.copyWith(right: 0)) ??
EdgeInsets.all(0);
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(
top: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.top
: 0,
bottom: widget.positionedIndex == 0
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
bottom: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(
left: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.left
: 0,
right: widget.positionedIndex == 0
? widget.padding!.right
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
right: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.right
: 0,
) ??
EdgeInsets.all(0);
EdgeInsets get _trailingSliverPadding =>
widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0);
void _schedulePositionNotificationUpdate() {
if (!updateScheduled) {
updateScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
if (registeredElements.value == null) {
updateScheduled = false;
return;
}
final positions = <ItemPosition>[];
RenderViewport? viewport;
for (var element in registeredElements.value!) {
final RenderBox box = element.renderObject as RenderBox;
viewport ??= RenderAbstractViewport.of(box) as RenderViewport?;
final IndexedKey key = element.widget.key as IndexedKey;
if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport!.getOffsetToReveal(box, 0).offset;
final itemOffset = reveal -
viewport.offset.pixels +
viewport.anchor * viewport.size.height;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: itemOffset.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (itemOffset + box.size.height).round() /
scrollController.position.viewportDimension));
} else {
final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: (widget.reverse
? scrollController.position.viewportDimension -
(itemOffset + box.size.width)
: itemOffset)
.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (widget.reverse
? scrollController.position.viewportDimension -
itemOffset
: (itemOffset + box.size.width))
.round() /
scrollController.position.viewportDimension));
}
}
widget.itemPositionsNotifier?.itemPositions.value = positions;
updateScheduled = false;
});
}
}
}
@@ -0,0 +1,35 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// Widget whose [Element] calls a callback when the element is mounted.
class PostMountCallback extends StatelessWidget {
/// Creates a [PostMountCallback] widget.
const PostMountCallback({required this.child, this.callback, Key? key})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Callback to call when the element for this widget is mounted.
final void Function()? callback;
@override
StatelessElement createElement() => _PostMountCallbackElement(this);
@override
Widget build(BuildContext context) => child;
}
class _PostMountCallbackElement extends StatelessElement {
_PostMountCallbackElement(PostMountCallback widget) : super(widget);
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final PostMountCallback postMountCallback = widget as PostMountCallback;
postMountCallback.callback?.call();
}
}
@@ -0,0 +1,76 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'viewport.dart';
/// A version of [CustomScrollView] that allows does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information.
class UnboundedCustomScrollView extends CustomScrollView {
const UnboundedCustomScrollView({
Key? key,
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
bool shrinkWrap = false,
Key? center,
double anchor = 0.0,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
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<Widget> 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,
);
}
}
@@ -0,0 +1,591 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'item_positions_listener.dart';
import 'item_positions_notifier.dart';
import 'positioned_list.dart';
import 'post_mount_callback.dart';
/// Number of screens to scroll when scrolling a long distance.
const int _screenScrollCount = 2;
/// A scrollable list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [ScrollablePositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [initialScrollIndex] positioned
/// at a particular [initialAlignment].
///
/// The [itemScrollController] can be used to scroll or jump to particular items
/// in the list. The [itemPositionsNotifier] can be used to get a list of items
/// currently laid out by the list.
///
/// All other parameters are the same as specified in [ListView].
class ScrollablePositionedList extends StatefulWidget {
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder].
const ScrollablePositionedList.builder({
required this.itemCount,
required this.itemBuilder,
Key? key,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.findChildIndexCallback,
}) : assert(itemCount != null),
assert(itemBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
separatorBuilder = null,
super(key: key);
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder] and separators provided by [separatorBuilder].
const ScrollablePositionedList.separated({
required this.itemCount,
required this.itemBuilder,
required this.separatorBuilder,
Key? key,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.findChildIndexCallback,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert(separatorBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
super(key: key);
/// Called to find the new index of a child based on its key in case of reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// Called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// Controller for jumping or scrolling to an item.
final ItemScrollController? itemScrollController;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
/// Index of an item to initially align within the viewport.
final int initialScrollIndex;
/// Determines where the leading edge of the item at [initialScrollIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double initialAlignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// The minimum cache extent used by the underlying scroll lists.
/// See [ScrollView.cacheExtent].
///
/// Note that the [ScrollablePositionedList] uses two lists to simulate long
/// scrolls, so using the [ScrollController.scrollTo] method may result
/// in builds of widgets that would otherwise already be built in the
/// cache extent.
final double? minCacheExtent;
@override
State<StatefulWidget> 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<void> scrollTo({
required int index,
double alignment = 0,
required Duration duration,
Curve curve = Curves.linear,
List<double> opacityAnimationWeights = const [40, 20, 40],
}) {
assert(_scrollableListState != null);
assert(opacityAnimationWeights.length == 3);
assert(duration > Duration.zero);
return _scrollableListState!._scrollTo(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
}
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(_scrollableListState == null);
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
class _ScrollablePositionedListState extends State<ScrollablePositionedList>
with TickerProviderStateMixin {
/// Details for the primary (active) [ListView].
var primary = _ListDisplayDetails(const ValueKey('Ping'));
/// Details for the secondary (transitional) [ListView] that is temporarily
/// shown when scrolling a long distance.
var secondary = _ListDisplayDetails(const ValueKey('Pong'));
final opacity = ProxyAnimation(const AlwaysStoppedAnimation<double>(0));
void Function() startAnimationCallback = () {};
bool _isTransitioning = false;
@override
void initState() {
super.initState();
ItemPosition? initialPosition = PageStorage.of(context)!.readState(context);
primary.target = initialPosition?.index ?? widget.initialScrollIndex;
primary.alignment =
initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) {
primary.target = widget.itemCount - 1;
}
widget.itemScrollController?._attach(this);
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
}
@override
void deactivate() {
widget.itemScrollController?._detach();
super.deactivate();
}
@override
void dispose() {
primary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
super.dispose();
}
@override
void didUpdateWidget(ScrollablePositionedList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemScrollController?._scrollableListState == this) {
oldWidget.itemScrollController?._detach();
}
if (widget.itemScrollController?._scrollableListState != this) {
widget.itemScrollController?._detach();
widget.itemScrollController?._attach(this);
}
if (widget.itemCount == 0) {
setState(() {
primary.target = 0;
secondary.target = 0;
});
} else {
if (primary.target > widget.itemCount - 1) {
setState(() {
primary.target = widget.itemCount - 1;
});
}
if (secondary.target > widget.itemCount - 1) {
setState(() {
secondary.target = widget.itemCount - 1;
});
}
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final cacheExtent = _cacheExtent(constraints);
return GestureDetector(
onPanDown: (_) => _stopScroll(canceled: true),
excludeFromSemantics: true,
child: Stack(
children: <Widget>[
PostMountCallback(
key: primary.key,
callback: startAnimationCallback,
child: FadeTransition(
opacity: ReverseAnimation(opacity),
child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning,
child: PositionedList(
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
positionedIndex: primary.target,
controller: primary.scrollController,
itemPositionsNotifier: primary.itemPositionsNotifier,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: primary.alignment,
physics: widget.physics,
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<ScrollNotification>(
onNotification: (_) => false,
child: PositionedList(
itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount,
itemPositionsNotifier: secondary.itemPositionsNotifier,
positionedIndex: secondary.target,
controller: secondary.scrollController,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
cacheExtent: cacheExtent,
alignment: secondary.alignment,
physics: widget.physics,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
],
),
);
},
);
}
double _cacheExtent(BoxConstraints constraints) => max(
constraints.maxHeight * _screenScrollCount,
widget.minCacheExtent ?? 0,
);
void _jumpTo({required int index, required double alignment}) {
_stopScroll(canceled: true);
if (index > widget.itemCount - 1) {
index = widget.itemCount - 1;
}
setState(() {
primary.scrollController.jumpTo(0);
primary.target = index;
primary.alignment = alignment;
});
}
Future<void> _scrollTo({
required int index,
required double alignment,
required Duration duration,
Curve curve = Curves.linear,
required List<double> 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<void> _startScroll({
required int index,
required double alignment,
required Duration duration,
Curve curve = Curves.linear,
required List<double> 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<void>();
final endCompleter = Completer<void>();
startAnimationCallback = () {
SchedulerBinding.instance!.addPostFrameCallback((_) {
startAnimationCallback = () {};
opacity.parent = _opacityAnimation(opacityAnimationWeights).animate(
AnimationController(vsync: this, duration: duration)..forward());
secondary.scrollController.jumpTo(-direction *
(_screenScrollCount *
primary.scrollController.position.viewportDimension -
alignment *
secondary.scrollController.position.viewportDimension));
startCompleter.complete(primary.scrollController.animateTo(
primary.scrollController.offset + direction * scrollAmount,
duration: duration,
curve: curve));
endCompleter.complete(secondary.scrollController
.animateTo(0, duration: duration, curve: curve));
});
};
setState(() {
// TODO: _startScroll can be re-entrant, which invalidates this assert.
// assert(!_isTransitioning);
secondary.target = index;
secondary.alignment = alignment;
_isTransitioning = true;
});
await Future.wait<void>([startCompleter.future, endCompleter.future]);
_stopScroll();
}
}
void _stopScroll({bool canceled = false}) {
if (!_isTransitioning) {
return;
}
if (canceled) {
if (primary.scrollController.hasClients) {
primary.scrollController.jumpTo(primary.scrollController.offset);
}
if (secondary.scrollController.hasClients) {
secondary.scrollController.jumpTo(secondary.scrollController.offset);
}
}
setState(() {
if (opacity.value >= 0.5) {
// Secondary [ListView] is more visible than the primary; make it the
// new primary.
var temp = primary;
primary = secondary;
secondary = temp;
}
_isTransitioning = false;
opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
final startOpacity = 0.0;
final endOpacity = 1.0;
return TweenSequence<double>(<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: ConstantTween<double>(startOpacity),
weight: opacityAnimationWeights[0]),
TweenSequenceItem<double>(
tween: Tween<double>(begin: startOpacity, end: endOpacity),
weight: opacityAnimationWeights[1]),
TweenSequenceItem<double>(
tween: ConstantTween<double>(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;
}
@@ -0,0 +1,315 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A render object that is bigger on the inside.
///
/// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information.
class UnboundedViewport extends Viewport {
UnboundedViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
Key? center,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
}) : _anchor = anchor,
super(
key: key,
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
slivers: slivers);
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
// version.
final double _anchor;
@override
double get anchor => _anchor;
@override
RenderViewport createRenderObject(BuildContext context) {
return UnboundedRenderViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor,
offset: offset,
cacheExtent: cacheExtent,
);
}
}
/// A render object that is bigger on the inside.
///
/// Version of [RenderViewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [RenderViewport]
/// for more information.
///
// Differences from [RenderViewport] are marked with a //***** Differences
// comment.
class UnboundedRenderViewport extends RenderViewport {
/// Creates a viewport for [RenderSliver] objects.
UnboundedRenderViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
}) : _anchor = anchor,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
children: children);
static const int _maxLayoutCycles = 10;
double _anchor;
// Out-of-band data computed during layout.
late double _minScrollExtent;
late double _maxScrollExtent;
bool _hasVisualOverflow = false;
/// This value is set during layout based on the [CacheExtentStyle].
///
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
/// of the viewport multiplied by the requested cache extent, which is still
/// expressed in pixels.
double? _calculatedCacheExtent;
@override
double get anchor => _anchor;
@override
set anchor(double value) {
assert(value != null);
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
@override
void performResize() {
super.performResize();
// TODO: Figure out why this override is needed as a result of
// https://github.com/flutter/flutter/pull/61973 and see if it can be
// removed somehow.
switch (axis) {
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
}
@override
Rect describeSemanticsClip(RenderSliver? child) {
assert(axis != null);
if (_calculatedCacheExtent == null) {
return semanticBounds;
}
switch (axis) {
case Axis.vertical:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - _calculatedCacheExtent!,
semanticBounds.right,
semanticBounds.bottom + _calculatedCacheExtent!,
);
default:
return Rect.fromLTRB(
semanticBounds.left - _calculatedCacheExtent!,
semanticBounds.top,
semanticBounds.right + _calculatedCacheExtent!,
semanticBounds.bottom,
);
}
}
@override
void performLayout() {
if (center == null) {
assert(firstChild == null);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
assert(center!.parent == this);
late double mainAxisExtent;
late double crossAxisExtent;
switch (axis) {
case Axis.vertical:
mainAxisExtent = size.height;
crossAxisExtent = size.width;
break;
case Axis.horizontal:
mainAxisExtent = size.width;
crossAxisExtent = size.height;
break;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
var count = 0;
do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
final minScrollOffset = math.min(top, maxScrollOffset);
if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset))
break;
// *** End of difference from [RenderViewport].
}
count += 1;
} while (count < _maxLayoutCycles);
assert(() {
if (count >= _maxLayoutCycles) {
assert(count != 1);
throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their '
'slivers or their ViewportOffset decide that the offset should be corrected '
'to take into account information collected during that layout.\n'
'In the case of this RenderViewport object, however, this happened $count '
'times and still there was no consensus on the scroll offset. This usually '
'indicates a bug. Specifically, it means that one of the following three '
'problems is being experienced by the RenderViewport object:\n'
' * One of the RenderSliver children or the ViewportOffset have a bug such'
' that they always think that they need to correct the offset regardless.\n'
' * Some combination of the RenderSliver children and the ViewportOffset'
' have a bad interaction such that one applies a correction then another'
' applies a reverse correction, leading to an infinite loop of corrections.\n'
' * There is a pathological case that would eventually resolve, but it is'
' so complicated that it cannot be resolved in any reasonable number of'
' layout passes.');
}
return true;
}());
}
double _attemptLayout(
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
assert(!mainAxisExtent.isNaN);
assert(mainAxisExtent >= 0.0);
assert(crossAxisExtent.isFinite);
assert(crossAxisExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final double centerOffset = mainAxisExtent * anchor - correctedOffset;
final double reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final double forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final double reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final double forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final RenderSliver? leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final double result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0.0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0.0, -centerOffset),
overlap:
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
}
@@ -34,11 +34,6 @@ dependencies:
photo_manager: ^1.2.6+1
photo_view: ^0.12.0
rxdart: ^0.27.0
scrollable_positioned_list:
git:
url: https://github.com/HayesGordon/flutter.widgets.git
ref: feat/child-index-callback
path: packages/scrollable_positioned_list
share_plus: ^2.0.3
shimmer: ^2.0.0
stream_chat_flutter_core: ^3.1.1
@@ -0,0 +1,298 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
void main() {
Future<void> setUpWidgetTest(
WidgetTester tester, {
ItemScrollController? itemScrollController,
ItemPositionsListener? itemPositionsListener,
bool reverse = false,
EdgeInsets? padding,
int initialScrollIndex = 0,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: ScrollablePositionedList.builder(
itemCount: itemCount,
itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox(
width: itemWidth,
child: Text('Item $index'),
),
itemPositionsListener: itemPositionsListener,
scrollDirection: Axis.horizontal,
reverse: reverse,
padding: padding,
initialScrollIndex: initialScrollIndex,
),
),
);
}
testWidgets('List positioned with 0 at left', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
expect(tester.getTopLeft(find.text('Item 0')).dx, 0);
expect(tester.getBottomRight(find.text('Item 9')).dx, screenWidth);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemTrailingEdge,
1 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 0 at right', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemPositionsListener: itemPositionsListener, reverse: true);
expect(tester.getBottomRight(find.text('Item 0')).dx, screenWidth);
expect(tester.getTopLeft(find.text('Item 9')).dx, 0);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemTrailingEdge,
1 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 2, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 1'), findsNothing);
expect(tester.getTopLeft(find.text('Item 2')).dx, 0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemTrailingEdge,
1 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 11)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 100 (not already on screen)',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 99'), findsNothing);
expect(find.text('Item 100'), findsOneWidget);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemTrailingEdge,
1 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('Jump to 100', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 100);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 100')).dx, 0);
expect(tester.getBottomRight(find.text('Item 109')).dy, screenWidth);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemTrailingEdge,
1 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemLeadingEdge,
9 / 10);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('padding test - centered sliver at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
);
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 1')),
const Offset(10 + itemWidth, 10));
expect(tester.getBottomRight(find.text('Item 1')),
const Offset(10 + 2 * itemWidth, screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-100, 0));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(200, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10 + 2 * itemWidth, 10));
expect(tester.getTopLeft(find.text('Item 3')),
const Offset(10 + 3 * itemWidth, 10));
});
testWidgets('padding test - reversed - centered sliver at right',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
reverse: true,
);
expect(tester.getTopRight(find.text('Item 0')),
const Offset(screenWidth - 10, 10));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - (10 + itemWidth), 10));
expect(tester.getBottomLeft(find.text('Item 1')),
const Offset(screenWidth - (10 + 2 * itemWidth), screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(100, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10));
});
testWidgets('padding test - reversed - centered sliver not at right',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
reverse: true,
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-200, 0));
await tester.pumpAndSettle();
expect(tester.getTopRight(find.text('Item 0')),
const Offset(screenWidth - 10, 10));
expect(tester.getTopRight(find.text('Item 2')),
const Offset(screenWidth - (10 + 2 * itemWidth), 10));
expect(tester.getTopRight(find.text('Item 3')),
const Offset(screenWidth - (10 + 3 * itemWidth), 10));
});
}
@@ -0,0 +1,363 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const defaultItemCount = 500;
const cacheExtent = itemHeight * 2;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<void> setUpWidgetTest(
WidgetTester tester, {
int topItem = 0,
ScrollController? scrollController,
double anchor = 0,
int itemCount = defaultItemCount,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: PositionedList(
itemCount: itemCount,
positionedIndex: topItem,
alignment: anchor,
controller: scrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier,
cacheExtent: cacheExtent,
),
),
);
}
testWidgets('short list', (WidgetTester tester) async {
await setUpWidgetTest(tester, itemCount: 5);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 4'), findsOneWidget);
expect(find.text('Item 5'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
1 / 2);
});
testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 9'), findsOneWidget);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemTrailingEdge,
11 / 10);
});
testWidgets('List positioned with 5 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.pump();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 1);
await tester.pump();
expect(find.text('Item 20'), findsNothing);
expect(find.text('Item 19'), findsOneWidget);
expect(find.text('Item 10'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemLeadingEdge,
9 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
1);
});
testWidgets('List positioned with 20 at halfway',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 0.5);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
0.5);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
0.5 + itemHeight / screenHeight);
});
testWidgets('List positioned with 20 half off top of screen',
(WidgetTester tester) async {
await setUpWidgetTest(tester,
topItem: 20, anchor: -(itemHeight / screenHeight) / 2);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
-(itemHeight / screenHeight) / 2);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
(itemHeight / screenHeight) / 2);
});
testWidgets('List positioned with 5 at top then scroll up 2',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, 2 * itemHeight));
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at top then scroll down 1/2',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight));
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 / 20);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemLeadingEdge,
17 / 20);
});
testWidgets('List positioned with 0 at top scroll up 5',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester, scrollController: scrollController);
await tester.pump();
scrollController.jumpTo(5 * itemHeight);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
});
testWidgets('List positioned with 5 at top then scroll up 2 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(-2 * itemHeight);
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets(
'List positioned with 5 at top then scroll down 20 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(20 * itemHeight);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 23)
.itemLeadingEdge,
-2 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 24)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 25)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-21 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
-20 / 10);
});
testWidgets('List positioned with 5 at top and initial scroll offset',
(WidgetTester tester) async {
final scrollController =
ScrollController(initialScrollOffset: -2 * itemHeight);
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
}
@@ -0,0 +1,270 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const defaultItemCount = 500;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<void> setUpWidgetTest(
WidgetTester tester, {
int topItem = 0,
ScrollController? scrollController,
double anchor = 0,
int itemCount = defaultItemCount,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: PositionedList(
itemCount: itemCount,
positionedIndex: topItem,
alignment: anchor,
controller: scrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier,
reverse: true,
),
),
);
}
testWidgets('short list', (WidgetTester tester) async {
await setUpWidgetTest(tester, itemCount: 5);
await tester.pump();
expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight);
expect(find.text('Item 4'), findsOneWidget);
expect(find.text('Item 5'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
1 / 2);
});
testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester);
await tester.pump();
expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight);
expect(tester.getTopLeft(find.text('Item 9')).dy, 0);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.pump();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemTrailingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 15 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 15, anchor: 0);
await tester.pump();
expect(find.text('Item 14'), findsNothing);
expect(find.text('Item 15'), findsOneWidget);
expect(find.text('Item 24'), findsOneWidget);
expect(find.text('Item 25'), findsNothing);
});
testWidgets('List positioned with 15 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 15, anchor: 1);
await tester.pump();
expect(find.text('Item 15'), findsNothing);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 4'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 15)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemLeadingEdge,
9 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
});
testWidgets('List positioned with 5 at bottom then scroll up 2',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, 2 * itemHeight));
await tester.pump();
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemTrailingEdge,
1 / 10);
});
testWidgets('List positioned with 0 at bottom scroll to item 5',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester, scrollController: scrollController);
await tester.pump();
scrollController.jumpTo(5 * itemHeight);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
});
testWidgets(
'List positioned with 5 at bottom then scroll up 2 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(2 * itemHeight);
await tester.pump();
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(find.text('Item 16'), findsOneWidget);
expect(find.text('Item 17'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 16)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at bottom and initial scroll offset',
(WidgetTester tester) async {
final scrollController =
ScrollController(initialScrollOffset: 2 * itemHeight);
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(find.text('Item 16'), findsOneWidget);
expect(find.text('Item 17'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 16)
.itemTrailingEdge,
1);
});
}
@@ -0,0 +1,239 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
void main() {
Future<void> setUpWidgetTest(
WidgetTester tester, {
ItemScrollController? itemScrollController,
ItemPositionsListener? itemPositionsListener,
EdgeInsets? padding,
int initialIndex = 0,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: ScrollablePositionedList.builder(
itemCount: itemCount,
initialScrollIndex: initialIndex,
itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
itemPositionsListener: itemPositionsListener,
reverse: true,
padding: padding,
),
),
);
}
testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
expect(tester.getBottomRight(find.text('Item 0')).dy, screenHeight);
expect(tester.getTopLeft(find.text('Item 9')).dy, 0);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 1 then 2 (both already on screen)',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 1, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 0'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 1)
.itemLeadingEdge,
0);
expect(tester.getBottomRight(find.text('Item 1')).dy, screenHeight);
unawaited(
itemScrollController.scrollTo(index: 2, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 1'), findsNothing);
expect(tester.getBottomRight(find.text('Item 2')).dy, screenHeight);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 11)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 5 (already on screen) and then back to 0',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 5, duration: scrollDuration));
await tester.pumpAndSettle();
unawaited(
itemScrollController.scrollTo(index: 0, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 9'), findsOneWidget);
expect(find.text('Item 10'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
1);
});
testWidgets('Scroll to 100 (not already on screen)',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 99'), findsNothing);
expect(find.text('Item 100'), findsOneWidget);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('Jump to 100', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 100);
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 100')).dy, screenHeight);
expect(tester.getTopLeft(find.text('Item 109')).dy, 0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 109)
.itemTrailingEdge,
1);
});
testWidgets('padding test - centered sliver at bottom',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
);
expect(tester.getBottomLeft(find.text('Item 0')),
const Offset(10, screenHeight - 10));
expect(tester.getBottomLeft(find.text('Item 1')),
const Offset(10, screenHeight - (10 + itemHeight)));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - 10, screenHeight - (10 + 2 * itemHeight)));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 100));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10));
});
testWidgets('padding test - centered sliver not at bottom',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -200));
await tester.pumpAndSettle();
expect(tester.getBottomLeft(find.text('Item 0')),
const Offset(10, screenHeight - 10));
expect(tester.getBottomLeft(find.text('Item 2')),
const Offset(10, screenHeight - (10 + 2 * itemHeight)));
expect(tester.getBottomLeft(find.text('Item 3')),
const Offset(10, screenHeight - (10 + 3 * itemHeight)));
});
}
@@ -0,0 +1,261 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const separatorHeight = screenHeight / 20.0;
const defaultItemCount = 500;
const cacheExtent = itemHeight * 2;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<void> setUpWidgetTest(
WidgetTester tester, {
int topItem = 0,
ScrollController? scrollController,
double anchor = 0,
int itemCount = defaultItemCount,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: PositionedList(
itemCount: itemCount,
positionedIndex: topItem,
alignment: anchor,
controller: scrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier,
cacheExtent: cacheExtent,
),
),
);
}
testWidgets('Empty list', (WidgetTester tester) async {
await setUpWidgetTest(tester, itemCount: 0);
expect(find.text('Item 0'), findsNothing);
expect(find.text('Separator 0'), findsNothing);
});
testWidgets('Short list', (WidgetTester tester) async {
await setUpWidgetTest(tester, itemCount: 3);
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 0'), findsOneWidget);
expect(find.text('Item 1'), findsOneWidget);
expect(find.text('Separator 1'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Separator 2'), findsNothing);
expect(find.text('Item 3'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemTrailingEdge,
_screenProportion(numberOfItems: 3, numberOfSeparators: 2));
});
testWidgets('Short list centered at 1 scrolled up',
(WidgetTester tester) async {
await setUpWidgetTest(tester, itemCount: 3, topItem: 1);
await tester.drag(
find.byType(PositionedList), const Offset(0, 2 * itemHeight));
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 0'), findsOneWidget);
expect(find.text('Item 1'), findsOneWidget);
expect(find.text('Separator 1'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Separator 2'), findsNothing);
expect(find.text('Item 3'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemTrailingEdge,
_screenProportion(numberOfItems: 3, numberOfSeparators: 2));
});
testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.pump();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Separator 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Separator 10'), findsOneWidget);
expect(find.text('Item 11'), findsOneWidget);
expect(find.text('Separator 11'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 11)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 1);
await tester.pump();
expect(find.text('Item 20'), findsNothing);
expect(find.text('Item 19'), findsOneWidget);
expect(find.text('Separator 19'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Separator 13'), findsOneWidget);
expect(find.text('Item 13'), findsOneWidget);
expect(find.text('Separator 12'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 13)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
});
testWidgets('List positioned with item 20 at halfway',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 0.5);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
0.5);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
0.5 + itemHeight / screenHeight);
});
testWidgets('List positioned with item 20 half off top of screen',
(WidgetTester tester) async {
await setUpWidgetTest(tester,
topItem: 20, anchor: -(itemHeight / screenHeight) / 2);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
_screenProportion(numberOfItems: 0.5, numberOfSeparators: 0));
});
testWidgets('List positioned with 5 at top then scroll up 2 items',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(find.byType(PositionedList),
const Offset(0, 2 * (itemHeight + separatorHeight)));
await tester.pump();
expect(find.text('Separator 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
_screenProportion(numberOfItems: -1, numberOfSeparators: -1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemHeight + numberOfSeparators * separatorHeight) /
screenHeight;
@@ -0,0 +1,608 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/src/scroll_view.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const separatorHeight = screenHeight / 20.0;
const defaultItemCount = 500;
const scrollDuration = Duration(seconds: 1);
const scrollDurationTolerance = Duration(milliseconds: 1);
const tolerance = 1e-3;
void main() {
Future<void> setUpWidgetTest(
WidgetTester tester, {
Key? key,
ItemScrollController? itemScrollController,
ItemPositionsListener? itemPositionsListener,
int initialIndex = 0,
double initialAlignment = 0.0,
int? itemCount,
ScrollPhysics? physics,
bool addSemanticIndexes = true,
int? semanticChildCount,
EdgeInsets? padding,
bool addRepaintBoundaries = true,
bool addAutomaticKeepAlives = true,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: ScrollablePositionedList.separated(
itemCount: itemCount ?? defaultItemCount,
itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
key: key,
itemPositionsListener: itemPositionsListener,
initialScrollIndex: initialIndex,
initialAlignment: initialAlignment,
physics: physics,
addSemanticIndexes: addSemanticIndexes,
semanticChildCount: semanticChildCount,
padding: padding,
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
),
),
);
}
testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
expect(
itemPositionsListener.itemPositions.value
.where((position) => position.index == 7),
isEmpty);
});
testWidgets('List positioned with 0 at top - use default values',
(WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: ScrollablePositionedList.separated(
itemCount: defaultItemCount,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
itemPositionsListener: itemPositionsListener,
),
),
);
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
expect(
itemPositionsListener.itemPositions.value
.where((position) => position.index == 7),
isEmpty);
});
testWidgets('List positioned with 5 at top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemPositionsListener: itemPositionsListener, initialIndex: 5);
expect(find.text('Item 4'), findsNothing);
expect(find.text('Separator 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Separator 10'), findsOneWidget);
expect(find.text('Item 11'), findsOneWidget);
expect(find.text('Separator 11'), findsNothing);
expect(
itemPositionsListener.itemPositions.value
.where((position) => position.index == 4),
isEmpty);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
});
testWidgets('List positioned with 9 at middle', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemPositionsListener: itemPositionsListener,
initialIndex: 9,
initialAlignment: 0.5);
expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2);
expect(tester.getTopLeft(find.text('Item 8')).dy,
screenHeight / 2 - itemHeight - separatorHeight);
expect(tester.getTopLeft(find.text('Item 10')).dy,
screenHeight / 2 + itemHeight + separatorHeight);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemLeadingEdge,
0.5);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 8)
.itemLeadingEdge,
0.5 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 10)
.itemLeadingEdge,
0.5 + _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
});
testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester,
itemPositionsListener: itemPositionsListener,
itemScrollController: itemScrollController);
unawaited(itemScrollController.scrollTo(
index: 9,
duration: scrollDuration,
alignment: -(itemHeight / screenHeight) / 2));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration + scrollDurationTolerance);
expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
_screenProportion(numberOfItems: 0.5, numberOfSeparators: 0));
});
testWidgets('Jump to 9 half way off top', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester,
itemPositionsListener: itemPositionsListener,
itemScrollController: itemScrollController);
itemScrollController.jumpTo(
index: 9, alignment: -(itemHeight / screenHeight) / 2);
await tester.pump();
expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 9)
.itemTrailingEdge,
_screenProportion(numberOfItems: 0.5, numberOfSeparators: 0));
});
testWidgets('List positioned with 9 at middle scroll to 16 at bottom',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
initialIndex: 9,
initialAlignment: 0.5);
unawaited(itemScrollController.scrollTo(
index: 16, duration: scrollDuration, alignment: 1.0));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration + scrollDurationTolerance);
expect(tester.getBottomRight(find.text('Item 15')).dy,
screenHeight - separatorHeight);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 15)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1));
});
testWidgets('physics', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
physics: const BouncingScrollPhysics());
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
itemScrollController.jumpTo(index: 0);
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
});
testWidgets('correct index semantics', (WidgetTester tester) async {
await setUpWidgetTest(tester, initialIndex: 5);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 4 * itemHeight));
await tester.pumpAndSettle();
final indexSemantics3 = tester.widget<IndexedSemantics>(find.ancestor(
of: find.text('Item 3'), matching: find.byType(IndexedSemantics)));
expect(indexSemantics3.index, 3);
final indexSemantics4 = tester.widget<IndexedSemantics>(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<CustomScrollView>(find.byType(UnboundedCustomScrollView));
expect(customScrollView.semanticChildCount, 30);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
final customScrollView2 =
tester.widget<CustomScrollView>(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<CustomScrollView>(find.byType(UnboundedCustomScrollView));
expect(customScrollView.semanticChildCount, defaultItemCount);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
final customScrollView2 =
tester.widget<CustomScrollView>(find.byType(UnboundedCustomScrollView));
expect(customScrollView2.semanticChildCount, defaultItemCount);
});
testWidgets('padding test - centered at top', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
);
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 1')),
const Offset(10, 10 + itemHeight + separatorHeight));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - 10, 10 + itemHeight + separatorHeight));
unawaited(
itemScrollController.scrollTo(index: 494, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -500));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at top',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 200));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10, 10 + 2 * (separatorHeight + itemHeight)));
expect(
tester.getTopRight(find.text('Item 3')),
const Offset(
screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight)));
});
testWidgets('no repaint bounderies', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
addRepaintBoundaries: false,
);
expect(
tester
.widgetList(find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(RepaintBoundary)))
.length,
lessThan(5));
});
testWidgets('no automatic keep alives', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
addAutomaticKeepAlives: false,
);
expect(
find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(AutomaticKeepAlive)),
findsNothing);
});
testWidgets('List can be keyed', (WidgetTester tester) async {
final key = ValueKey('key');
await setUpWidgetTest(tester, key: key);
expect(find.byKey(key), findsOneWidget);
});
testWidgets('Empty list then update to single item list',
(WidgetTester tester) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
final itemCount = ValueNotifier<int>(0);
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<int>(
valueListenable: itemCount,
builder: (context, itemCount, child) {
return ScrollablePositionedList.separated(
initialScrollIndex: 0,
initialAlignment: 0,
itemCount: itemCount,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
);
},
),
),
);
await tester.pumpAndSettle();
itemCount.value = 1;
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 0'), findsNothing);
});
testWidgets('ItemPositions: Empty list then update to 10 items list',
(WidgetTester tester) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
final itemCount = ValueNotifier<int>(0);
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<int>(
valueListenable: itemCount,
builder: (context, itemCount, child) {
return ScrollablePositionedList.separated(
initialScrollIndex: 0,
initialAlignment: 0,
itemCount: itemCount,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsNothing);
expect(find.text('Separator 0'), findsNothing);
expect(itemPositionsListener.itemPositions.value, []);
itemCount.value = 10;
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(itemPositionsListener.itemPositions.value, isNotEmpty);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
expect(
itemPositionsListener.itemPositions.value
.where((position) => position.index == 7),
isEmpty);
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemHeight + numberOfSeparators * separatorHeight) /
screenHeight;
@@ -0,0 +1,220 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/src/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0;
const separatorWidth = screenWidth / 20.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
const tolerance = 10e-5;
void main() {
Future<void> setUpWidgetTest(
WidgetTester tester, {
ItemScrollController? itemScrollController,
ItemPositionsListener? itemPositionsListener,
bool reverse = false,
EdgeInsets? padding,
int initialScrollIndex = 0,
}) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
await tester.pumpWidget(
MaterialApp(
home: ScrollablePositionedList.separated(
itemCount: itemCount,
itemScrollController: itemScrollController,
itemBuilder: (context, index) => SizedBox(
width: itemWidth,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
width: separatorWidth,
child: Text('Separator $index'),
),
itemPositionsListener: itemPositionsListener,
scrollDirection: Axis.horizontal,
reverse: reverse,
padding: padding,
initialScrollIndex: initialScrollIndex,
),
),
);
}
testWidgets('List positioned with 0 at left', (WidgetTester tester) async {
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener);
expect(tester.getTopLeft(find.text('Item 0')).dx, 0);
expect(tester.getBottomLeft(find.text('Item 1')).dx,
itemWidth + separatorWidth);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 1)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
});
testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 2, duration: scrollDuration));
await tester.pump();
await tester.pump(scrollDuration);
expect(find.text('Item 1'), findsNothing);
expect(tester.getTopLeft(find.text('Item 2')).dx, 0);
expect(
tester.getTopLeft(find.text('Item 3')).dx, itemWidth + separatorWidth);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
});
testWidgets('Scroll to 100 (not already on screen)',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
expect(find.text('Item 99'), findsNothing);
expect(find.text('Item 100'), findsOneWidget);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 101)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
});
testWidgets('Jump to 100', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener);
itemScrollController.jumpTo(index: 100);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 100')).dx, 0);
expect(tester.getTopLeft(find.text('Item 101')).dx,
itemWidth + separatorWidth);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 100)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 101)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
});
testWidgets('padding test - centered sliver at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
);
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 1')),
const Offset(10 + itemWidth + separatorWidth, 10));
expect(tester.getBottomRight(find.text('Item 1')),
const Offset(10 + 2 * itemWidth + separatorWidth, screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 494, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(300, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10 + 2 * (itemWidth + separatorWidth), 10));
expect(tester.getTopLeft(find.text('Item 3')),
const Offset(10 + 3 * (itemWidth + separatorWidth), 10));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
closeTo(
10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth),
tolerance));
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemWidth + numberOfSeparators * separatorWidth) /
screenHeight;