Merge pull request #743 from GetStream/feat/positioned-list-experiment

fix(ui): MessageListView initial index
This commit is contained in:
Salvatore Giordano
2021-10-29 11:34:12 +02:00
committed by GitHub
26 changed files with 6293 additions and 99 deletions
+1
View File
@@ -4,6 +4,7 @@ analyzer:
exclude:
- packages/*/lib/**/*.g.dart
- packages/*/lib/src/emoji/**
- packages/*/lib/scrollable_positioned_list/**
- packages/*/lib/**/*.freezed.dart
linter:
@@ -6,6 +6,8 @@
- Fixed message highlight animation alignment in `MessageListView`
- [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order.
- Fixed `MessageListView` initialIndex not working in some cases.
- Improved `MessageListView` rendering in case of reordering.
- Fix image thumbnail generation when using Stream CDN
✅ Added
@@ -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,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,98 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// A registry to track some [Element]s in the tree.
class RegistryWidget extends StatefulWidget {
/// Creates a [RegistryWidget].
const RegistryWidget({Key? key, this.elementNotifier, required this.child})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Contains the current set of all [Element]s created by
/// [RegisteredElementWidget]s in the tree below this widget.
///
/// Note that if there is another [RegistryWidget] in this widget's subtree
/// that registry, and not this one, will collect elements in its subtree.
final ValueNotifier<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 {
const _InheritedRegistryWidget({
Key? key,
required this.state,
required Widget child,
}) : super(key: key, child: child);
final _RegistryWidgetState state;
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}
class _RegisteredElement extends ProxyElement {
_RegisteredElement(ProxyWidget widget) : super(widget);
@override
void notifyClients(ProxyWidget oldWidget) {}
late _RegistryWidgetState _registryWidgetState;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void unmount() {
_registryWidgetState.registeredElements.remove(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
super.unmount();
}
}
@@ -0,0 +1,31 @@
import 'dart:ui' show hashValues;
import 'package:flutter/foundation.dart';
/// {@template indexed_key}
/// Creates an indexed key that delegates its [operator==] to the given key.
///
/// It contains an index used in [ScrollablePositionedList].
/// {@endtemplate}
class IndexedKey extends LocalKey {
/// {@macro indexed_key}
const IndexedKey(this.key, this.index);
/// The key to which this this delegates its [operator==].
final Key? key;
/// Index used to show position in a list.
final int index;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is IndexedKey && other.key == key;
}
@override
int get hashCode => hashValues(runtimeType, key);
@override
String toString() => '(IndexedKey) index: $index, key: $key';
}
@@ -0,0 +1,62 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart';
/// Provides a listenable iterable of [itemPositions] of items that are on
/// screen and their locations.
abstract class ItemPositionsListener {
/// Creates an [ItemPositionsListener] that can be used by a
/// [ScrollablePositionedList] to return the current position of items.
factory ItemPositionsListener.create() => ItemPositionsNotifier();
/// The position of items that are at least partially visible in the viewport.
ValueListenable<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 * (index.hashCode + 7) + 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 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart';
/// Internal implementation of [ItemPositionsListener].
class ItemPositionsNotifier implements ItemPositionsListener {
@override
final ValueNotifier<Iterable<ItemPosition>> itemPositions = ValueNotifier([]);
}
@@ -0,0 +1,370 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/element_registry.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/indexed_key.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
/// A list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [PositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [positionIndex] positioned at a
/// particular [alignment]. See [ItemScrollController.jumpTo] for an
/// explanation of alignment.
///
/// All other parameters are the same as specified in [ListView].
class PositionedList extends StatefulWidget {
/// Create a [PositionedList].
const PositionedList({
Key? key,
required this.itemCount,
required this.itemBuilder,
this.separatorBuilder,
this.controller,
this.itemPositionsNotifier,
this.positionedIndex = 0,
this.alignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.padding,
this.cacheExtent,
this.semanticChildCount,
this.findChildIndexCallback,
this.addSemanticIndexes = true,
this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true,
}) : assert((positionedIndex == 0) || (positionedIndex < itemCount),
'positionedIndex cannot be 0 and must be smaller than itemCount'),
super(key: key);
/// Called to find the new index of a child based on its key in case of
/// reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// If not null, called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
final ScrollController? controller;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
/// Index of an item to initially align to a position within the viewport
/// defined by [alignment].
final int positionedIndex;
/// Determines where the leading edge of the item at [positionedIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double alignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// {@macro flutter.widgets.scrollable.cacheExtent}
final double? cacheExtent;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
@override
State<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(
widget.positionedIndex * 2 - (index + 1),
),
childCount: widget.separatorBuilder == null
? widget.positionedIndex
: widget.positionedIndex * 2,
addSemanticIndexes: false,
findChildIndexCallback: widget.findChildIndexCallback,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
SliverPadding(
key: _centerKey,
padding: _centerSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex)
: _buildSeparatedListElement(
index + widget.positionedIndex * 2,
),
childCount: widget.itemCount != 0 ? 1 : 0,
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
if (widget.positionedIndex >= 0 &&
widget.positionedIndex < widget.itemCount - 1)
SliverPadding(
padding: _trailingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex + 1)
: _buildSeparatedListElement(
index + widget.positionedIndex * 2 + 1,
),
childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1),
findChildIndexCallback: widget.findChildIndexCallback,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
],
),
);
Widget _buildSeparatedListElement(int index) {
if (index.isEven) {
return _buildItem(index ~/ 2);
} else {
return widget.separatorBuilder!(context, index ~/ 2);
}
}
Widget _buildItem(int index) {
final child = widget.itemBuilder(context, index);
return RegisteredElementWidget(
key: IndexedKey(child.key, index),
child: widget.addSemanticIndexes
? IndexedSemantics(index: index, child: child)
: child,
);
}
EdgeInsets get _leadingSliverPadding =>
(widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(top: 0)
: widget.padding?.copyWith(bottom: 0)
: widget.reverse
? widget.padding?.copyWith(left: 0)
: widget.padding?.copyWith(right: 0)) ??
const EdgeInsets.all(0);
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(
top: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.top
: 0,
bottom:
widget.positionedIndex == 0 ? widget.padding!.bottom : 0,
) ??
const EdgeInsets.all(0)
: widget.padding?.copyWith(
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
bottom: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.bottom
: 0,
) ??
const EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(
left: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.left
: 0,
right: widget.positionedIndex == 0 ? widget.padding!.right : 0,
) ??
const EdgeInsets.all(0)
: widget.padding?.copyWith(
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
right: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.right
: 0,
) ??
const EdgeInsets.all(0);
EdgeInsets get _trailingSliverPadding =>
widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(bottom: 0) ?? const EdgeInsets.all(0)
: widget.padding?.copyWith(top: 0) ?? const EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(right: 0) ?? const EdgeInsets.all(0)
: widget.padding?.copyWith(left: 0) ?? const EdgeInsets.all(0);
void _schedulePositionNotificationUpdate() {
if (!updateScheduled) {
updateScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
if (registeredElements.value == null) {
updateScheduled = false;
return;
}
final positions = <ItemPosition>[];
RenderViewport? viewport;
for (final element in registeredElements.value!) {
final box = element.renderObject as RenderBox?;
viewport ??= RenderAbstractViewport.of(box) as RenderViewport?;
if (viewport == null || box == null) {
continue;
}
final key = element.widget.key as IndexedKey;
if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport.getOffsetToReveal(box, 0).offset;
if (!reveal.isFinite) continue;
final itemOffset = reveal -
viewport.offset.pixels +
viewport.anchor * viewport.size.height;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: itemOffset.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (itemOffset + box.size.height).round() /
scrollController.position.viewportDimension,
));
} else {
final itemOffset =
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
positions.add(ItemPosition(
index: key.index,
itemLeadingEdge: (widget.reverse
? scrollController.position.viewportDimension -
(itemOffset + box.size.width)
: itemOffset)
.round() /
scrollController.position.viewportDimension,
itemTrailingEdge: (widget.reverse
? scrollController.position.viewportDimension -
itemOffset
: (itemOffset + box.size.width))
.round() /
scrollController.position.viewportDimension,
));
}
}
widget.itemPositionsNotifier?.itemPositions.value = positions;
updateScheduled = false;
});
}
}
}
@@ -0,0 +1,35 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// Widget whose [Element] calls a callback when the element is mounted.
class PostMountCallback extends StatelessWidget {
/// Creates a [PostMountCallback] widget.
const PostMountCallback({required this.child, this.callback, Key? key})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Callback to call when the element for this widget is mounted.
final void Function()? callback;
@override
StatelessElement createElement() => _PostMountCallbackElement(this);
@override
Widget build(BuildContext context) => child;
}
class _PostMountCallbackElement extends StatelessElement {
_PostMountCallbackElement(PostMountCallback widget) : super(widget);
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final postMountCallback = widget as PostMountCallback;
postMountCallback.callback?.call();
}
}
@@ -0,0 +1,79 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/viewport.dart';
/// {@template custom_scroll_view}
/// A version of [CustomScrollView] that does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information.
/// {@endtemplate}
class UnboundedCustomScrollView extends CustomScrollView {
/// {@macro custom_scroll_view}
const UnboundedCustomScrollView({
Key? key,
Axis scrollDirection = Axis.vertical,
bool reverse = false,
ScrollController? controller,
bool? primary,
ScrollPhysics? physics,
bool shrinkWrap = false,
Key? center,
double anchor = 0.0,
double? cacheExtent,
List<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,593 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_listener.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/post_mount_callback.dart';
/// Number of screens to scroll when scrolling a long distance.
const int _screenScrollCount = 2;
/// A scrollable list of widgets similar to [ListView], except scroll control
/// and position reporting is based on index rather than pixel offset.
///
/// [ScrollablePositionedList] lays out children in the same way as [ListView].
///
/// The list can be displayed with the item at [initialScrollIndex] positioned
/// at a particular [initialAlignment].
///
/// The [itemScrollController] can be used to scroll or jump to particular items
/// in the list. The [itemPositionsNotifier] can be used to get a list of items
/// currently laid out by the list.
///
/// All other parameters are the same as specified in [ListView].
class ScrollablePositionedList extends StatefulWidget {
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder].
const ScrollablePositionedList.builder({
required this.itemCount,
required this.itemBuilder,
Key? key,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.findChildIndexCallback,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
separatorBuilder = null,
super(key: key);
/// Create a [ScrollablePositionedList] whose items are provided by
/// [itemBuilder] and separators provided by [separatorBuilder].
const ScrollablePositionedList.separated({
required this.itemCount,
required this.itemBuilder,
required this.separatorBuilder,
Key? key,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.physics,
this.semanticChildCount,
this.padding,
this.addSemanticIndexes = true,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.minCacheExtent,
this.findChildIndexCallback,
}) : assert(separatorBuilder != null, 'seperatorBuilder cannot be null'),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
super(key: key);
/// Called to find the new index of a child based on its key in case of
/// reordering.
///
/// If not provided, a child widget may not map to its existing [RenderObject]
/// when the order in which children are returned from [builder] changes.
/// This may result in state-loss.
///
/// This callback should take an input [Key], and it should return the
/// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback;
/// Number of items the [itemBuilder] can produce.
final int itemCount;
/// Called to build children for the list with
/// 0 <= index < itemCount.
final IndexedWidgetBuilder itemBuilder;
/// Called to build separators for between each item in the list.
/// Called with 0 <= index < itemCount - 1.
final IndexedWidgetBuilder? separatorBuilder;
/// Controller for jumping or scrolling to an item.
final ItemScrollController? itemScrollController;
/// Notifier that reports the items laid out in the list after each frame.
final ItemPositionsNotifier? itemPositionsNotifier;
/// Index of an item to initially align within the viewport.
final int initialScrollIndex;
/// Determines where the leading edge of the item at [initialScrollIndex]
/// should be placed.
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
final double initialAlignment;
/// The axis along which the scroll view scrolls.
///
/// Defaults to [Axis.vertical].
final Axis scrollDirection;
/// Whether the view scrolls in the reading direction.
///
/// Defaults to false.
///
/// See [ScrollView.reverse].
final bool reverse;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// The minimum cache extent used by the underlying scroll lists.
/// See [ScrollView.cacheExtent].
///
/// Note that the [ScrollablePositionedList] uses two lists to simulate long
/// scrolls, so using the [ScrollController.scrollTo] method may result
/// in builds of widgets that would otherwise already be built in the
/// cache extent.
final double? minCacheExtent;
@override
State<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, '_scrollableListState cannot be null');
assert(opacityAnimationWeights.length == 3,
'opacityAnimationWeights.length is not equal to 3');
assert(duration > Duration.zero,
'duration needs to be bigger than Duration.zero');
return _scrollableListState!._scrollTo(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
}
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(
_scrollableListState == null, '_scrollableListState needs to be null');
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
class _ScrollablePositionedListState extends State<ScrollablePositionedList>
with TickerProviderStateMixin {
/// Details for the primary (active) [ListView].
_ListDisplayDetails primary = _ListDisplayDetails(const ValueKey('Ping'));
/// Details for the secondary (transitional) [ListView] that is temporarily
/// shown when scrolling a long distance.
_ListDisplayDetails secondary = _ListDisplayDetails(const ValueKey('Pong'));
final opacity = ProxyAnimation(const AlwaysStoppedAnimation<double>(0));
void Function() startAnimationCallback = () {};
bool _isTransitioning = false;
@override
void initState() {
super.initState();
final ItemPosition? initialPosition =
PageStorage.of(context)!.readState(context);
primary
..target = initialPosition?.index ?? widget.initialScrollIndex
..alignment = initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) {
primary.target = widget.itemCount - 1;
}
widget.itemScrollController?._attach(this);
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
}
@override
void deactivate() {
widget.itemScrollController?._detach();
super.deactivate();
}
@override
void dispose() {
primary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
super.dispose();
}
@override
void didUpdateWidget(ScrollablePositionedList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemScrollController?._scrollableListState == this) {
oldWidget.itemScrollController?._detach();
}
if (widget.itemScrollController?._scrollableListState != this) {
widget.itemScrollController?._detach();
widget.itemScrollController?._attach(this);
}
if (widget.itemCount == 0) {
primary.target = 0;
secondary.target = 0;
} else {
if (primary.target > widget.itemCount - 1) {
primary.target = widget.itemCount - 1;
}
if (secondary.target > widget.itemCount - 1) {
secondary.target = widget.itemCount - 1;
}
}
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final cacheExtent = _cacheExtent(constraints);
return GestureDetector(
onPanDown: (_) => _stopScroll(canceled: true),
excludeFromSemantics: true,
child: Stack(
children: <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
..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
..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.
final temp = primary;
primary = secondary;
secondary = temp;
}
_isTransitioning = false;
opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
const startOpacity = 0.0;
const 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,326 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore_for_file: lines_longer_than_80_chars
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// {@template unbounded_viewport}
/// A render object that is bigger on the inside.
///
/// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information.
/// description
class UnboundedViewport extends Viewport {
/// {@macro unbounded_viewport}
UnboundedViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
Key? center,
double? cacheExtent,
List<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) =>
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) {
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
@override
void performResize() {
super.performResize();
// TODO: Figure out why this override is needed as a result of
// https://github.com/flutter/flutter/pull/61973 and see if it can be
// removed somehow.
switch (axis) {
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
}
@override
Rect describeSemanticsClip(RenderSliver? child) {
if (_calculatedCacheExtent == null) {
return semanticBounds;
}
switch (axis) {
case Axis.vertical:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - _calculatedCacheExtent!,
semanticBounds.right,
semanticBounds.bottom + _calculatedCacheExtent!,
);
default:
return Rect.fromLTRB(
semanticBounds.left - _calculatedCacheExtent!,
semanticBounds.top,
semanticBounds.right + _calculatedCacheExtent!,
semanticBounds.bottom,
);
}
}
@override
void performLayout() {
if (center == null) {
assert(firstChild == null, 'firstChild cannot be null');
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0, 0);
return;
}
assert(center!.parent == this, 'center.parent cannot be equal to this');
late double mainAxisExtent;
late double crossAxisExtent;
switch (axis) {
case Axis.vertical:
mainAxisExtent = size.height;
crossAxisExtent = size.width;
break;
case Axis.horizontal:
mainAxisExtent = size.width;
crossAxisExtent = size.height;
break;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
var count = 0;
do {
correction = _attemptLayout(
mainAxisExtent,
crossAxisExtent,
offset.pixels + centerOffsetAdjustment,
);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max<double>(math.min(0, top), bottom);
final minScrollOffset = math.min<double>(top, maxScrollOffset);
if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) {
break;
}
// *** End of difference from [RenderViewport].
}
count += 1;
} while (count < _maxLayoutCycles);
assert(() {
if (count >= _maxLayoutCycles) {
assert(count != 1, 'count not equal to 1');
throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their '
'slivers or their ViewportOffset decide that the offset should be corrected '
'to take into account information collected during that layout.\n'
'In the case of this RenderViewport object, however, this happened $count '
'times and still there was no consensus on the scroll offset. This usually '
'indicates a bug. Specifically, it means that one of the following three '
'problems is being experienced by the RenderViewport object:\n'
' * One of the RenderSliver children or the ViewportOffset have a bug such'
' that they always think that they need to correct the offset regardless.\n'
' * Some combination of the RenderSliver children and the ViewportOffset'
' have a bad interaction such that one applies a correction then another'
' applies a reverse correction, leading to an infinite loop of corrections.\n'
' * There is a pathological case that would eventually resolve, but it is'
' so complicated that it cannot be resolved in any reasonable number of'
' layout passes.',
);
}
return true;
}(), 'count needs to be bigger than _maxLayoutCycles');
}
double _attemptLayout(
double mainAxisExtent,
double crossAxisExtent,
double correctedOffset,
) {
assert(!mainAxisExtent.isNaN, 'assert mainAxisExtent.isNaN');
assert(mainAxisExtent >= 0.0, 'assert mainAxisExtent >= 0.0');
assert(crossAxisExtent.isFinite, 'assert crossAxisExtent.isFinite');
assert(crossAxisExtent >= 0.0, 'assert crossAxisExtent >= 0.0');
assert(correctedOffset.isFinite, 'assert correctedOffset.isFinite');
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final centerOffset = mainAxisExtent * anchor - correctedOffset;
final reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0, -centerOffset),
overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection,
SliverGeometry childLayoutGeometry,
) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
}
@@ -97,7 +97,7 @@ class ImageAttachment extends AttachmentWidget {
return _buildImageAttachment(
context,
CachedNetworkImage(
cacheKey: imageUrl,
cacheKey: imageUri.replace(queryParameters: {}).toString(),
height: size?.height,
width: size?.width,
placeholder: (context, __) {
@@ -6,8 +6,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/info_tile.dart';
import 'package:stream_chat_flutter/src/message_widget.dart';
@@ -145,7 +144,8 @@ class MessageListView extends StatefulWidget {
this.threadBuilder,
this.onThreadTap,
this.dateDividerBuilder,
this.scrollPhysics = const ClampingScrollPhysics(),
this.scrollPhysics =
const ClampingScrollPhysics(), // we need to use ClampingScrollPhysics to avoid the list view to animate and break while loading
this.initialScrollIndex,
this.initialAlignment,
this.scrollController,
@@ -225,7 +225,7 @@ class MessageListView extends StatefulWidget {
final ItemPositionsListener? itemPositionListener;
/// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics;
final ScrollPhysics? scrollPhysics;
/// Called when message item gets swiped
final OnMessageSwiped? onMessageSwiped;
@@ -296,7 +296,7 @@ class MessageListView extends StatefulWidget {
class _MessageListViewState extends State<MessageListView> {
ItemScrollController? _scrollController;
void Function(Message)? _onThreadTap;
bool _showScrollToBottom = false;
final ValueNotifier<bool> _showScrollToBottom = ValueNotifier(false);
late final ItemPositionsListener _itemPositionListener;
int? _messageListLength;
StreamChannelState? streamChannel;
@@ -306,12 +306,17 @@ class _MessageListViewState extends State<MessageListView> {
final initialScrollIndex = widget.initialScrollIndex;
if (initialScrollIndex != null) return initialScrollIndex;
if (streamChannel!.initialMessageId != null) {
final messages = streamChannel!.channel.state!.messages;
final messages = streamChannel!.channel.state!.messages
.where(widget.messageFilter ??
defaultMessageFilter(
streamChannel!.channel.client.state.currentUser!.id,
))
.toList(growable: false);
final totalMessages = messages.length;
final messageIndex =
messages.indexWhere((e) => e.id == streamChannel!.initialMessageId);
final index = totalMessages - messageIndex;
if (index != 0) return index - 1;
if (index != 0) return index + 1;
return index;
}
return 0;
@@ -320,7 +325,7 @@ class _MessageListViewState extends State<MessageListView> {
double get _initialAlignment {
final initialAlignment = widget.initialAlignment;
if (initialAlignment != null) return initialAlignment;
return 0;
return 0.1;
}
bool _isInitialMessage(String id) => streamChannel!.initialMessageId == id;
@@ -329,7 +334,6 @@ class _MessageListViewState extends State<MessageListView> {
bool get _isThreadConversation => widget.parentMessage != null;
bool _topPaginationActive = false;
bool _bottomPaginationActive = false;
int initialIndex = 0;
@@ -337,6 +341,8 @@ class _MessageListViewState extends State<MessageListView> {
List<Message> messages = <Message>[];
Map<String, int> messagesIndex = {};
bool initialMessageHighlightComplete = false;
bool _inBetweenList = false;
@@ -382,6 +388,9 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildListView(List<Message> data) {
messages = data;
for (var index = 0; index < messages.length; index++) {
messagesIndex[messages[index].id] = index;
}
final newMessagesListLength = messages.length;
if (_messageListLength != null) {
@@ -394,10 +403,6 @@ class _MessageListViewState extends State<MessageListView> {
initialAlignment = first.itemLeadingEdge;
}
}
} else if (!_topPaginationActive && _upToDate) {
// Reset the index in-case we send any new message
initialIndex = 0;
initialAlignment = 0;
}
}
@@ -441,7 +446,6 @@ class _MessageListViewState extends State<MessageListView> {
onStartOfPage: () async {
_inBetweenList = false;
if (!_upToDate) {
_topPaginationActive = false;
_bottomPaginationActive = true;
return _paginateData(
streamChannel,
@@ -451,7 +455,6 @@ class _MessageListViewState extends State<MessageListView> {
},
onEndOfPage: () async {
_inBetweenList = false;
_topPaginationActive = true;
_bottomPaginationActive = false;
return _paginateData(
streamChannel,
@@ -462,17 +465,26 @@ class _MessageListViewState extends State<MessageListView> {
_inBetweenList = true;
},
child: ScrollablePositionedList.separated(
key: _upToDate
? null
: ValueKey(initialIndex + initialAlignment),
key: (initialIndex != 0 && initialAlignment != 0)
? ValueKey('$initialIndex-$initialAlignment')
: null,
itemPositionsListener: _itemPositionListener,
initialScrollIndex: initialIndex,
initialAlignment: initialAlignment,
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: widget.reverse,
addAutomaticKeepAlives: false,
itemCount: itemCount,
findChildIndexCallback: (Key key) {
final indexedKey = key as IndexedKey;
final valueKey = indexedKey.key as ValueKey<String>?;
if (valueKey != null) {
final index = messagesIndex[valueKey.value];
if (index != null) {
return ((index + 2) * 2) - 1;
}
}
},
// Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages)
// eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)|
@@ -624,14 +636,30 @@ class _MessageListViewState extends State<MessageListView> {
} else {
messageWidget = buildMessage(message, messages, i - 2);
}
return messageWidget;
return KeyedSubtree(
key: ValueKey(message.id),
child: messageWidget,
);
},
),
),
);
},
),
if (widget.showScrollToBottom) _buildScrollToBottom(),
BetterStreamBuilder<bool>(
stream: streamChannel!.channel.state!.isUpToDateStream,
initialData: streamChannel!.channel.state!.isUpToDate,
builder: (context, snapshot) => ValueListenableBuilder<bool>(
valueListenable: _showScrollToBottom,
child: _buildScrollToBottom(),
builder: (context, value, child) {
if (!snapshot || value) {
return child!;
}
return const Offstage();
},
),
),
if (widget.showFloatingDateDivider)
_buildFloatingDateDivider(itemCount),
],
@@ -751,24 +779,15 @@ class _MessageListViewState extends State<MessageListView> {
.index;
}
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2(
streamChannel!.channel.state!.isUpToDateStream.distinct(),
streamChannel!.channel.state!.unreadCountStream.distinct(),
(bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount),
),
Widget _buildScrollToBottom() => StreamBuilder<int>(
stream: streamChannel!.channel.state!.unreadCountStream,
builder: (_, snapshot) {
if (snapshot.hasError) {
return const Offstage();
} else if (!snapshot.hasData) {
return const Offstage();
}
final isUpToDate = snapshot.data!.item1;
final showScrollToBottom = !isUpToDate || _showScrollToBottom;
if (!showScrollToBottom) {
return const Offstage();
}
final unreadCount = snapshot.data!.item2;
final unreadCount = snapshot.data!;
final showUnreadCount = unreadCount > 0 &&
streamChannel!.channel.state!.members.any((e) =>
e.userId ==
@@ -783,16 +802,21 @@ class _MessageListViewState extends State<MessageListView> {
children: [
FloatingActionButton(
backgroundColor: _streamTheme.colorTheme.barsBg,
onPressed: () {
onPressed: () async {
if (unreadCount > 0) {
streamChannel!.channel.markRead();
}
if (!_upToDate) {
_bottomPaginationActive = false;
_topPaginationActive = false;
streamChannel!.reloadChannel();
initialAlignment = 0;
initialIndex = 0;
await streamChannel!.reloadChannel();
WidgetsBinding.instance?.addPostFrameCallback((_) {
_scrollController!.jumpTo(index: 0);
});
} else {
setState(() => _showScrollToBottom = false);
_showScrollToBottom.value = false;
_scrollController!.scrollTo(
index: 0,
duration: const Duration(seconds: 1),
@@ -854,9 +878,8 @@ class _MessageListViewState extends State<MessageListView> {
int index,
) {
final messageWidget = buildMessage(message, messages, index);
return VisibilityDetector(
key: ValueKey<String>('BOTTOM-MESSAGE-${message.id}'),
key: ValueKey('visibility: ${message.id}'),
onVisibilityChanged: (visibility) {
final isVisible = visibility.visibleBounds != Rect.zero;
if (isVisible) {
@@ -868,8 +891,8 @@ class _MessageListViewState extends State<MessageListView> {
}
}
if (mounted) {
if (_showScrollToBottom == isVisible) {
setState(() => _showScrollToBottom = !isVisible);
if (_showScrollToBottom.value == isVisible) {
_showScrollToBottom.value = !isVisible;
}
}
},
@@ -948,16 +971,11 @@ class _MessageListViewState extends State<MessageListView> {
return defaultMessageWidget;
}
Widget buildMessage(
Message message,
List<Message> messages,
int index,
) {
Widget buildMessage(Message message, List<Message> messages, int index) {
if ((message.type == 'system' || message.type == 'error') &&
message.text?.isNotEmpty == true) {
return widget.systemMessageBuilder?.call(context, message) ??
SystemMessage(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
onMessageTap: (message) {
if (widget.onSystemMessageTap != null) {
@@ -1037,7 +1055,6 @@ class _MessageListViewState extends State<MessageListView> {
members.firstWhereOrNull((e) => e.user!.id == currentUser!.id);
Widget messageWidget = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
reverse: isMyMessage,
showReactions: !message.isDeleted,
@@ -1049,23 +1066,20 @@ class _MessageListViewState extends State<MessageListView> {
showSendingIndicator: showSendingIndicator,
showUserAvatar: showUserAvatar,
onQuotedMessageTap: (quotedMessageId) async {
// ignore: prefer_function_declarations_over_variables
final scrollToIndex = () {
if (messages.map((e) => e.id).contains(quotedMessageId)) {
final index = messages.indexWhere((m) => m.id == quotedMessageId);
_scrollController?.scrollTo(
index: index,
duration: const Duration(milliseconds: 350),
index: index + 2, // +2 to account for loader and footer
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
alignment: 0.1,
);
};
if (messages.map((e) => e.id).contains(quotedMessageId)) {
scrollToIndex();
} else {
await streamChannel!.loadChannelAtMessage(quotedMessageId).then((_) {
WidgetsBinding.instance!.addPostFrameCallback((_) {
if (messages.map((e) => e.id).contains(quotedMessageId)) {
scrollToIndex();
}
});
await streamChannel!
.loadChannelAtMessage(quotedMessageId)
.then((_) async {
initialIndex = 21; // 19 + 2 | 19 is the index of the message
initialAlignment = 0.1;
});
}
},
@@ -1229,20 +1243,17 @@ class _MessageListViewState extends State<MessageListView> {
initialIndex = _initialIndex;
initialAlignment = _initialAlignment;
WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
if (_scrollController?.isAttached == true) {
_scrollController?.jumpTo(
index: initialIndex,
alignment: initialAlignment,
);
}
});
if (_scrollController?.isAttached == true) {
_scrollController?.jumpTo(
index: initialIndex,
alignment: initialAlignment,
);
}
_messageNewListener =
streamChannel!.channel.on(EventType.messageNew).listen((event) {
if (_upToDate) {
_bottomPaginationActive = false;
_topPaginationActive = false;
}
if (event.message?.parentId == widget.parentMessage?.id &&
event.message!.user!.id ==
@@ -608,6 +608,8 @@ class _MessageWidgetState extends State<MessageWidget>
final bottomRowPadding =
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
final showReactions = _shouldShowReactions;
return Material(
type: widget.message.pinned && widget.showPinHighlight
? MaterialType.card
@@ -671,17 +673,23 @@ class _MessageWidgetState extends State<MessageWidget>
SizedBox(width: avatarWidth + 4),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(
widget.reverse ? 12 : -12,
0,
0,
),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0,
),
child: _buildReactionIndicator(context),
),
visible: showReactions,
portal: showReactions
? Container(
transform:
Matrix4.translationValues(
widget.reverse ? 12 : -12,
0,
0,
),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0,
),
child: _buildReactionIndicator(
context,
),
)
: null,
portalAnchor:
Alignment(widget.reverse ? 1 : -1, -1),
childAnchor:
@@ -1036,9 +1044,7 @@ class _MessageWidgetState extends State<MessageWidget>
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (widget.showReactions &&
(widget.message.reactionCounts?.isNotEmpty == true) &&
!widget.message.isDeleted)
child: _shouldShowReactions
? GestureDetector(
onTap: () => _showMessageReactionsModalBottomSheet(context),
child: ReactionBubble(
@@ -1058,6 +1064,11 @@ class _MessageWidgetState extends State<MessageWidget>
);
}
bool get _shouldShowReactions =>
widget.showReactions &&
(widget.message.reactionCounts?.isNotEmpty == true) &&
!widget.message.isDeleted;
void _showMessageActionModalBottomSheet(BuildContext context) {
final channel = StreamChannel.of(context).channel;
@@ -34,7 +34,6 @@ dependencies:
photo_manager: ^1.2.6+1
photo_view: ^0.12.0
rxdart: ^0.27.0
scrollable_positioned_list: ^0.2.0-nullsafety.0
share_plus: ^2.0.3
shimmer: ^2.0.0
stream_chat_flutter_core: ^3.1.1
@@ -0,0 +1,298 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
void main() {
Future<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(itemWidth + 10, 10));
expect(tester.getBottomRight(find.text('Item 1')),
const Offset(10 + itemWidth * 2, screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-100, 0));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(200, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10 + itemWidth * 2, 10));
expect(tester.getTopLeft(find.text('Item 3')),
const Offset(10 + itemWidth * 3, 10));
});
testWidgets('padding test - reversed - centered sliver at right',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
padding: const EdgeInsets.all(10),
reverse: true,
);
expect(tester.getTopRight(find.text('Item 0')),
const Offset(screenWidth - 10, 10));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - (itemWidth + 10), 10));
expect(tester.getBottomLeft(find.text('Item 1')),
const Offset(screenWidth - (10 + itemWidth * 2), screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(100, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10));
});
testWidgets('padding test - reversed - centered sliver not at right',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
reverse: true,
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-200, 0));
await tester.pumpAndSettle();
expect(tester.getTopRight(find.text('Item 0')),
const Offset(screenWidth - 10, 10));
expect(tester.getTopRight(find.text('Item 2')),
const Offset(screenWidth - (10 + itemWidth * 2), 10));
expect(tester.getTopRight(find.text('Item 3')),
const Offset(screenWidth - (10 + itemWidth * 3), 10));
});
}
@@ -0,0 +1,363 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const defaultItemCount = 500;
const cacheExtent = itemHeight * 2;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<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, itemHeight * 2));
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at top then scroll down 1/2',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight));
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 / 20);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemLeadingEdge,
17 / 20);
});
testWidgets('List positioned with 0 at top scroll up 5',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester, scrollController: scrollController);
await tester.pump();
scrollController.jumpTo(itemHeight * 5);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
});
testWidgets('List positioned with 5 at top then scroll up 2 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(-2 * itemHeight);
await tester.pump();
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
testWidgets(
'List positioned with 5 at top then scroll down 20 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(itemHeight * 20);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 23)
.itemLeadingEdge,
-2 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 24)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 25)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-21 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
-20 / 10);
});
testWidgets('List positioned with 5 at top and initial scroll offset',
(WidgetTester tester) async {
final scrollController =
ScrollController(initialScrollOffset: -2 * itemHeight);
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(find.text('Item 12'), findsOneWidget);
expect(find.text('Item 13'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 12)
.itemTrailingEdge,
1);
});
}
@@ -0,0 +1,270 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const defaultItemCount = 500;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<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);
await tester.pump();
expect(find.text('Item 14'), findsNothing);
expect(find.text('Item 15'), findsOneWidget);
expect(find.text('Item 24'), findsOneWidget);
expect(find.text('Item 25'), findsNothing);
});
testWidgets('List positioned with 15 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 15, anchor: 1);
await tester.pump();
expect(find.text('Item 15'), findsNothing);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 4'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 15)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemTrailingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 14)
.itemLeadingEdge,
9 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
});
testWidgets('List positioned with 5 at bottom then scroll up 2',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(
find.byType(PositionedList), const Offset(0, itemHeight * 2));
await tester.pump();
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemTrailingEdge,
1 / 10);
});
testWidgets('List positioned with 0 at bottom scroll to item 5',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester, scrollController: scrollController);
await tester.pump();
scrollController.jumpTo(itemHeight * 5);
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Item 15'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 4)
.itemLeadingEdge,
-1 / 10);
});
testWidgets(
'List positioned with 5 at bottom then scroll up 2 programatically',
(WidgetTester tester) async {
final scrollController = ScrollController();
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
scrollController.jumpTo(itemHeight * 2);
await tester.pump();
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(find.text('Item 16'), findsOneWidget);
expect(find.text('Item 17'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 16)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at bottom and initial scroll offset',
(WidgetTester tester) async {
final scrollController =
ScrollController(initialScrollOffset: itemHeight * 2);
await setUpWidgetTest(tester,
topItem: 5, scrollController: scrollController);
expect(find.text('Item 6'), findsNothing);
expect(find.text('Item 7'), findsOneWidget);
expect(find.text('Item 16'), findsOneWidget);
expect(find.text('Item 17'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
-1 / 10);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 7)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 16)
.itemTrailingEdge,
1);
});
}
@@ -0,0 +1,239 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
void main() {
Future<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 - (itemHeight + 10)));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - 10, screenHeight - (10 + itemHeight * 2)));
unawaited(
itemScrollController.scrollTo(index: 490, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 100));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10));
});
testWidgets('padding test - centered sliver not at bottom',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -200));
await tester.pumpAndSettle();
expect(tester.getBottomLeft(find.text('Item 0')),
const Offset(10, screenHeight - 10));
expect(tester.getBottomLeft(find.text('Item 2')),
const Offset(10, screenHeight - (10 + itemHeight * 2)));
expect(tester.getBottomLeft(find.text('Item 3')),
const Offset(10, screenHeight - (10 + itemHeight * 3)));
});
}
@@ -0,0 +1,261 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/item_positions_notifier.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const separatorHeight = screenHeight / 20.0;
const defaultItemCount = 500;
const cacheExtent = itemHeight * 2;
void main() {
final itemPositionsNotifier = ItemPositionsListener.create();
Future<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, itemHeight * 2));
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 0'), findsOneWidget);
expect(find.text('Item 1'), findsOneWidget);
expect(find.text('Separator 1'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Separator 2'), findsNothing);
expect(find.text('Item 3'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemTrailingEdge,
_screenProportion(numberOfItems: 3, numberOfSeparators: 2));
});
testWidgets('List positioned with 0 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester);
await tester.pump();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 5 at top', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.pump();
expect(find.text('Item 4'), findsNothing);
expect(find.text('Separator 4'), findsNothing);
expect(find.text('Item 5'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Separator 10'), findsOneWidget);
expect(find.text('Item 11'), findsOneWidget);
expect(find.text('Separator 11'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemLeadingEdge,
0);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemLeadingEdge,
_screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 11)
.itemTrailingEdge,
1);
});
testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 1);
await tester.pump();
expect(find.text('Item 20'), findsNothing);
expect(find.text('Item 19'), findsOneWidget);
expect(find.text('Separator 19'), findsOneWidget);
expect(find.text('Item 14'), findsOneWidget);
expect(find.text('Separator 13'), findsOneWidget);
expect(find.text('Item 13'), findsOneWidget);
expect(find.text('Separator 12'), findsNothing);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 19)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
1);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 13)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
});
testWidgets('List positioned with item 20 at halfway',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 20, anchor: 0.5);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
0.5);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
0.5 + itemHeight / screenHeight);
});
testWidgets('List positioned with item 20 half off top of screen',
(WidgetTester tester) async {
await setUpWidgetTest(tester,
topItem: 20, anchor: -(itemHeight / screenHeight) / 2);
await tester.pump();
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemLeadingEdge,
_screenProportion(numberOfItems: -0.5, numberOfSeparators: 0));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 20)
.itemTrailingEdge,
_screenProportion(numberOfItems: 0.5, numberOfSeparators: 0));
});
testWidgets('List positioned with 5 at top then scroll up 2 items',
(WidgetTester tester) async {
await setUpWidgetTest(tester, topItem: 5);
await tester.drag(find.byType(PositionedList),
const Offset(0, 2 * (itemHeight + separatorHeight)));
await tester.pump();
expect(find.text('Separator 2'), findsNothing);
expect(find.text('Item 3'), findsOneWidget);
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
_screenProportion(numberOfItems: -1, numberOfSeparators: -1));
expect(
itemPositionsNotifier.itemPositions.value
.firstWhere((position) => position.index == 3)
.itemLeadingEdge,
0);
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemHeight + numberOfSeparators * separatorHeight) /
screenHeight;
@@ -0,0 +1,602 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/src/scroll_view.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemHeight = screenHeight / 10.0;
const separatorHeight = screenHeight / 20.0;
const defaultItemCount = 500;
const scrollDuration = Duration(seconds: 1);
const scrollDurationTolerance = Duration(milliseconds: 1);
const tolerance = 1e-3;
void main() {
Future<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));
await tester.pump();
await tester.pump();
await tester.pump(scrollDuration + scrollDurationTolerance);
expect(tester.getBottomRight(find.text('Item 15')).dy,
screenHeight - separatorHeight);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 15)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1));
});
testWidgets('physics', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(tester,
itemScrollController: itemScrollController,
physics: const BouncingScrollPhysics());
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
unawaited(
itemScrollController.scrollTo(index: 100, duration: scrollDuration));
await tester.pumpAndSettle();
itemScrollController.jumpTo(index: 0);
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 50));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')).dy, 0);
});
testWidgets('correct index semantics', (WidgetTester tester) async {
await setUpWidgetTest(tester, initialIndex: 5);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 4));
await tester.pumpAndSettle();
final indexSemantics3 = tester.widget<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, itemHeight + 10 + separatorHeight));
expect(tester.getTopRight(find.text('Item 1')),
const Offset(screenWidth - 10, itemHeight + 10 + separatorHeight));
unawaited(
itemScrollController.scrollTo(index: 494, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, -500));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at top',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(0, 200));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10, 10 + 2 * (separatorHeight + itemHeight)));
expect(
tester.getTopRight(find.text('Item 3')),
const Offset(
screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight)));
});
testWidgets('no repaint bounderies', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
addRepaintBoundaries: false,
);
expect(
tester
.widgetList(find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(RepaintBoundary)))
.length,
lessThan(5));
});
testWidgets('no automatic keep alives', (WidgetTester tester) async {
final itemScrollController = ItemScrollController();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
initialIndex: 2,
padding: const EdgeInsets.all(10),
addAutomaticKeepAlives: false,
);
expect(
find.descendant(
of: find.byType(ScrollablePositionedList),
matching: find.byType(AutomaticKeepAlive)),
findsNothing);
});
testWidgets('List can be keyed', (WidgetTester tester) async {
const key = ValueKey('key');
await setUpWidgetTest(tester, key: key);
expect(find.byKey(key), findsOneWidget);
});
testWidgets('Empty list then update to single item list',
(WidgetTester tester) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
final itemCount = ValueNotifier<int>(0);
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<int>(
valueListenable: itemCount,
builder: (context, itemCount, child) =>
ScrollablePositionedList.separated(
itemCount: itemCount,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
),
),
),
);
await tester.pumpAndSettle();
itemCount.value = 1;
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 0'), findsNothing);
});
testWidgets('ItemPositions: Empty list then update to 10 items list',
(WidgetTester tester) async {
tester.binding.window.devicePixelRatioTestValue = 1.0;
tester.binding.window.physicalSizeTestValue =
const Size(screenWidth, screenHeight);
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
final itemCount = ValueNotifier<int>(0);
await tester.pumpWidget(
MaterialApp(
home: ValueListenableBuilder<int>(
valueListenable: itemCount,
builder: (context, itemCount, child) =>
ScrollablePositionedList.separated(
itemCount: itemCount,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemBuilder: (context, index) => SizedBox(
height: itemHeight,
child: Text('Item $index'),
),
separatorBuilder: (context, index) => SizedBox(
height: separatorHeight,
child: Text('Separator $index'),
),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsNothing);
expect(find.text('Separator 0'), findsNothing);
expect(itemPositionsListener.itemPositions.value, []);
itemCount.value = 10;
await tester.pumpAndSettle();
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Separator 5'), findsOneWidget);
expect(find.text('Item 6'), findsOneWidget);
expect(find.text('Separator 6'), findsNothing);
expect(find.text('Item 7'), findsNothing);
expect(itemPositionsListener.itemPositions.value, isNotEmpty);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 0)
.itemLeadingEdge,
0);
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 5)
.itemTrailingEdge,
1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 6)
.itemTrailingEdge,
1);
expect(
itemPositionsListener.itemPositions.value
.where((position) => position.index == 7),
isEmpty);
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemHeight + numberOfSeparators * separatorHeight) /
screenHeight;
@@ -0,0 +1,220 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pedantic/pedantic.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
const screenHeight = 400.0;
const screenWidth = 400.0;
const itemWidth = screenWidth / 10.0;
const separatorWidth = screenWidth / 20.0;
const itemCount = 500;
const scrollDuration = Duration(seconds: 1);
const tolerance = 10e-5;
void main() {
Future<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(itemWidth + 10 + separatorWidth, 10));
expect(tester.getBottomRight(find.text('Item 1')),
const Offset(10 + itemWidth * 2 + separatorWidth, screenHeight - 10));
unawaited(
itemScrollController.scrollTo(index: 494, duration: scrollDuration));
await tester.pumpAndSettle();
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(-500, 0));
await tester.pumpAndSettle();
expect(tester.getBottomRight(find.text('Item 499')),
const Offset(screenWidth - 10, screenHeight - 10));
});
testWidgets('padding test - centered sliver not at left',
(WidgetTester tester) async {
final itemScrollController = ItemScrollController();
final itemPositionsListener = ItemPositionsListener.create();
await setUpWidgetTest(
tester,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
initialScrollIndex: 2,
padding: const EdgeInsets.all(10),
);
await tester.drag(
find.byType(ScrollablePositionedList), const Offset(300, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10));
expect(tester.getTopLeft(find.text('Item 2')),
const Offset(10 + 2 * (itemWidth + separatorWidth), 10));
expect(tester.getTopLeft(find.text('Item 3')),
const Offset(10 + 3 * (itemWidth + separatorWidth), 10));
expect(
itemPositionsListener.itemPositions.value
.firstWhere((position) => position.index == 2)
.itemLeadingEdge,
closeTo(
10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth),
tolerance));
});
}
double _screenProportion(
{required double numberOfItems, required double numberOfSeparators}) =>
(numberOfItems * itemWidth + numberOfSeparators * separatorWidth) /
screenHeight;
@@ -9,6 +9,14 @@ import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
import 'package:stream_chat_flutter_core/src/stream_channel.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart';
/// Default filter for the message list
bool Function(Message) defaultMessageFilter(String currentUserId) =>
(Message m) {
final isMyMessage = m.user?.id == currentUserId;
if (m.shadowed && !isMyMessage) return false;
return true;
};
/// [MessageListCore] is a simplified class that allows fetching a list of
/// messages while exposing UI builders.
///
@@ -132,25 +140,20 @@ class MessageListCoreState extends State<MessageListCore> {
? _streamChannel!.channel.state?.threads[widget.parentMessage!.id]
: _streamChannel!.channel.state?.messages;
bool defaultFilter(Message m) {
final isMyMessage = m.user?.id == _currentUser?.id;
if (m.shadowed && !isMyMessage) return false;
return true;
}
return BetterStreamBuilder<List<Message>>(
initialData: initialData,
comparator: const ListEquality().equals,
stream: messagesStream!.map(
(messages) =>
messages?.where(widget.messageFilter ?? defaultFilter).toList(
growable: false,
),
),
stream: messagesStream,
errorBuilder: widget.errorBuilder,
noDataBuilder: widget.loadingBuilder,
builder: (context, data) {
final messageList = data.reversed.toList(growable: false);
final messageList = data
.where(
widget.messageFilter ?? defaultMessageFilter(_currentUser!.id),
)
.toList(growable: false)
.reversed
.toList(growable: false);
if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) {
return widget.emptyBuilder(context);