chore(mobile): vendor scrollable_positioned_list 0.3.8

Copy the published 0.3.8 package unchanged into mobile/third_party and
depend on it by path, so the next commit can patch how it keys items.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-10-19 10:04:06 -07:00
parent 5730c83f7d
commit 1c6566104b
21 changed files with 2951 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ analyzer:
- windows/**
- macos/**
- linux/**
- third_party/**
linter:
rules:

View File

@@ -1275,10 +1275,9 @@ packages:
scrollable_positioned_list:
dependency: "direct main"
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
url: "https://pub.dev"
source: hosted
path: "third_party/scrollable_positioned_list"
relative: true
source: path
version: "0.3.8"
share_plus:
dependency: "direct main"

View File

@@ -43,7 +43,9 @@ dependencies:
package_info_plus: ^10.0.0
app_badge_plus: ^1.2.10
app_links: ^6.4.0
scrollable_positioned_list: ^0.3.8
# Vendored 0.3.8 with stable item keys; see third_party/scrollable_positioned_list/VENDORED.md.
scrollable_positioned_list:
path: third_party/scrollable_positioned_list
open_filex: ^4.7.0
path_provider: ^2.1.6
share_plus: ^13.3.0

View File

@@ -0,0 +1 @@
pubspec.lock

View File

@@ -0,0 +1,126 @@
# 0.3.8
* Add ScrollOffsetController to allow pixel-based changes in offset.
* Bump min sdk version to 2.15.0
# 0.3.7
* Add ScrollOffsetListener to allow listening to changes in scroll offset.
# 0.3.6
* Fix cache extents for horizontal lists
* scrollTo future doesn't complete until scrolling is done.
# 0.3.5
* Fix extraneous animation controller declaration in 0.3.4.
# 0.3.4
* Disposed the animation controller when disposing the scrollable list.
# 0.3.3
* Fix potential crash when reading from RenderBox.size.
# 0.3.2
* Re-apply Flutter framework bindings' null safety calls but set SDK
constraints correctly to 2.12.0 instead.
# 0.3.1
* Reverts change from 0.3.0 where the Flutter version constraint should have
been set to 2.12.0 instead of 2.10.5.
# 0.3.0
* Move to Flutter version 2.10.5 and update dependencies' null safety calls.
# 0.2.3
* Support shrink wrap.
# 0.2.2
* Move dependencies from pre-release versions to released versions.
# 0.2.1
* Fix crash on NaN or infinite offset.
# 0.2.0-nullsafety.0
* Update to null safety.
# 0.1.10
* Update the home page URL to fix
[issue #190](https://github.com/google/flutter.widgets/issues/190).
* Miscellaneous tweaks to the example.
* Added documentation to address
[issue #96](https://github.com/google/flutter.widgets/issues/96).
* Miscellaneous other cleanup.
* Restructured `_ScrollablePositionedListState` to try to simplify logic.
* Fixed an issue with `ItemScrollController.scrollTo` where it could scroll to
the wrong item if a non-zero `alignment` was specified and if the list was
manually scrolled by dragging.
# 0.1.9
* Fixed the example in `README.md`. Fixes
[issue #191](https://github.com/google/flutter.widgets/issues/191).
* Made the example runnable with `flutter run`. Fixes
[issue #211](https://github.com/google/flutter.widgets/issues/211).
* Updates to computation of semantic clip.
* Smoother transition between views on long scrolls.
* New controls over transition between views on long scrolls.
# 0.1.8
* Set updateScheduled to false when short circuiting due to empty list.
To fix https://github.com/google/flutter.widgets/issues/182.
# 0.1.7
* Apply viewport dimensions in UnboundedRenderedViewport.performResize.
To work around change in https://github.com/flutter/flutter/pull/61973
causing breakage
# 0.1.6
* Change to do local scroll (without a fade) whenever target item is found
within the cache.
* Added sdk constraints to the example.
* Moved `itemScrollControllerDetachment` to
`_ScrollablePositionedListState.deactivate`.
# 0.1.5
* Added minCacheExtent to ScrollablePositionedList
* Fixes the issue when item count updated from zero to one and `index` in
`itemBuilder` becomes `-1`. Fixes
[issue #104](https://github.com/google/flutter.widgets/issues/104).
# 0.1.4
* itemBuilders should not be called with indices > itemCount - 1. Fixes
[issue #42](https://github.com/google/flutter.widgets/issues/42) and
[issue #77](https://github.com/google/flutter.widgets/issues/77).
# 0.1.3
* Don't build items when `itemCount` is 0. Fixes
[issue #78](https://github.com/google/flutter.widgets/issues/78).
* Fix typos in `README.md`.
# 0.1.2
* Store scroll state in page storage to fix
[issue #43](https://github.com/google/flutter.widgets/issues/43).
# 0.1.1
* Fix padding for horizontal lists.
* Add `ScrollablePositionedList.separated` constructor to complete
[issue #34](https://github.com/google/flutter.widgets/issues/34).
* Add `isAttached` method to `ItemScrollController`.
# 0.1.0
* Properly bound `ScrollablePositionedList` to fix
[issue #23](https://github.com/google/flutter.widgets/issues/23).
* Allow `ScrollablePositionedList` alignment outside `[0..1]` to fix
[issue #31](https://github.com/google/flutter.widgets/issues/31).
* Moved `ScrollablePositionedList` example into `example` subdirectory.
# 0.0.1
* Added `ScrollablePositionedList`.

View File

@@ -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.

View File

@@ -0,0 +1,74 @@
# scrollable_positioned_list
A flutter list that allows scrolling to a specific item in the list.
Also allows determining what items are currently visible.
## Usage
A `ScrollablePositionedList` works much like the builder version of `ListView`
except that the list can be scrolled or jumped to a specific item.
### Example
A `ScrollablePositionedList` can be created with:
```dart
final ItemScrollController itemScrollController = ItemScrollController();
final ScrollOffsetController scrollOffsetController = ScrollOffsetController();
final ItemPositionsListener itemPositionsListener = ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener = ScrollOffsetListener.create()
ScrollablePositionedList.builder(
itemCount: 500,
itemBuilder: (context, index) => Text('Item $index'),
itemScrollController: itemScrollController,
scrollOffsetController: scrollOffsetController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
);
```
One then can scroll to a particular item with:
```dart
itemScrollController.scrollTo(
index: 150,
duration: Duration(seconds: 2),
curve: Curves.easeInOutCubic);
```
or jump to a particular item with:
```dart
itemScrollController.jumpTo(index: 150);
```
One can monitor what items are visible on screen with:
```dart
itemPositionsListener.itemPositions.addListener(() => ...);
```
### Experimental APIs (subject to bugs and changes)
Changes in scroll position can be monitored with:
```dart
scrollOffsetListener.changes.listen((event) => ...)
```
see `ScrollSum` in [this test](test/scroll_offset_listener_test.dart) for an example of how the current offset can be
calculated from the stream of scroll change deltas. This feature is new and experimental.
Changes in scroll position in pixels, relative to the current scroll position, can be made with:
```dart
scrollOffsetController.animateScroll(offset: 100, duration: Duration(seconds: 1));
```
A full example can be found in the example folder.
--------------------------------------------------------------------------------
This is not an officially supported Google product.

View File

@@ -0,0 +1,10 @@
# Vendored scrollable_positioned_list
- **Source:** [pub.dev `scrollable_positioned_list` 0.3.8](https://pub.dev/packages/scrollable_positioned_list/versions/0.3.8), from [google/flutter.widgets](https://github.com/google/flutter.widgets/tree/master/packages/scrollable_positioned_list).
- **Archive sha256:** `1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287`
- **License:** BSD-3-Clause, in `LICENSE`.
- **Copied:** `lib/`, `LICENSE`, `README.md`, `CHANGELOG.md`, `pubspec.yaml`, unchanged except one trailing space trimmed from `README.md`. The example and tests were left out.
## Local changes
None yet.

View File

@@ -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/item_positions_listener.dart';
export 'src/scrollable_positioned_list.dart';
export 'src/scroll_offset_listener.dart';

View File

@@ -0,0 +1,96 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// A registry to track some [Element]s in the tree.
class RegistryWidget extends StatefulWidget {
/// Creates a [RegistryWidget].
const RegistryWidget({Key? key, this.elementNotifier, required this.child})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Contains the current set of all [Element]s created by
/// [RegisteredElementWidget]s in the tree below this widget.
///
/// Note that if there is another [RegistryWidget] in this widget's subtree
/// that registry, and not this one, will collect elements in its subtree.
final ValueNotifier<Set<Element>?>? elementNotifier;
@override
State<StatefulWidget> createState() => _RegistryWidgetState();
}
/// A widget whose [Element] will be added its nearest ancestor
/// [RegistryWidget].
class RegisteredElementWidget extends ProxyWidget {
/// Creates a [RegisteredElementWidget].
const RegisteredElementWidget({Key? key, required Widget child})
: super(key: key, child: child);
@override
Element createElement() => _RegisteredElement(this);
}
class _RegistryWidgetState extends State<RegistryWidget> {
final Set<Element> registeredElements = {};
@override
Widget build(BuildContext context) => _InheritedRegistryWidget(
state: this,
child: widget.child,
);
}
class _InheritedRegistryWidget extends InheritedWidget {
final _RegistryWidgetState state;
const _InheritedRegistryWidget(
{Key? key, required this.state, required Widget child})
: super(key: key, child: child);
@override
bool updateShouldNotify(InheritedWidget oldWidget) => true;
}
class _RegisteredElement extends ProxyElement {
_RegisteredElement(ProxyWidget widget) : super(widget);
@override
void notifyClients(ProxyWidget oldWidget) {}
late _RegistryWidgetState _registryWidgetState;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final _inheritedRegistryWidget =
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
_registryWidgetState = _inheritedRegistryWidget.state;
_registryWidgetState.registeredElements.add(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
}
@override
void unmount() {
_registryWidgetState.registeredElements.remove(this);
_registryWidgetState.widget.elementNotifier?.value =
_registryWidgetState.registeredElements;
super.unmount();
}
}

View File

@@ -0,0 +1,61 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_notifier.dart';
import 'scrollable_positioned_list.dart';
/// Provides a listenable iterable of [itemPositions] of items that are on
/// screen and their locations.
abstract class ItemPositionsListener {
/// Creates an [ItemPositionsListener] that can be used by a
/// [ScrollablePositionedList] to return the current position of items.
factory ItemPositionsListener.create() => ItemPositionsNotifier();
/// The position of items that are at least partially visible in the viewport.
ValueListenable<Iterable<ItemPosition>> get itemPositions;
}
/// Position information for an item in the list.
class ItemPosition {
/// Create an [ItemPosition].
const ItemPosition(
{required this.index,
required this.itemLeadingEdge,
required this.itemTrailingEdge});
/// Index of the item.
final int index;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the leading edge of the item.
///
/// May be negative if the item is partially visible.
final double itemLeadingEdge;
/// Distance in proportion of the viewport's main axis length from the leading
/// edge of the viewport to the trailing edge of the item.
///
/// May be greater than one if the item is partially visible.
final double itemTrailingEdge;
@override
bool operator ==(dynamic other) {
if (other.runtimeType != runtimeType) return false;
final ItemPosition otherPosition = other;
return otherPosition.index == index &&
otherPosition.itemLeadingEdge == itemLeadingEdge &&
otherPosition.itemTrailingEdge == itemTrailingEdge;
}
@override
int get hashCode =>
31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) +
itemTrailingEdge.hashCode;
@override
String toString() =>
'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)';
}

View File

@@ -0,0 +1,13 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'item_positions_listener.dart';
/// Internal implementation of [ItemPositionsListener].
class ItemPositionsNotifier implements ItemPositionsListener {
@override
final ValueNotifier<Iterable<ItemPosition>> itemPositions = ValueNotifier([]);
}

View File

@@ -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/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'element_registry.dart';
import 'item_positions_listener.dart';
import 'item_positions_notifier.dart';
import 'scroll_view.dart';
import 'wrapping.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.shrinkWrap = false,
this.physics,
this.padding,
this.cacheExtent,
this.semanticChildCount,
this.addSemanticIndexes = true,
this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert((positionedIndex == 0) || (positionedIndex < itemCount)),
super(key: key);
/// 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;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// 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,
shrinkWrap: widget.shrinkWrap,
semanticChildCount: widget.semanticChildCount ?? widget.itemCount,
slivers: <Widget>[
if (widget.positionedIndex > 0)
SliverPadding(
padding: _leadingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(widget.positionedIndex - (index + 1))
: _buildSeparatedListElement(
2 * widget.positionedIndex - (index + 1)),
childCount: widget.separatorBuilder == null
? widget.positionedIndex
: 2 * widget.positionedIndex,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
SliverPadding(
key: _centerKey,
padding: _centerSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex),
childCount: widget.itemCount != 0 ? 1 : 0,
addSemanticIndexes: false,
addRepaintBoundaries: widget.addRepaintBoundaries,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
),
),
),
if (widget.positionedIndex >= 0 &&
widget.positionedIndex < widget.itemCount - 1)
SliverPadding(
padding: _trailingSliverPadding,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => widget.separatorBuilder == null
? _buildItem(index + widget.positionedIndex + 1)
: _buildSeparatedListElement(
index + 2 * widget.positionedIndex + 1),
childCount: widget.separatorBuilder == null
? widget.itemCount - widget.positionedIndex - 1
: 2 * (widget.itemCount - widget.positionedIndex - 1),
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) {
return RegisteredElementWidget(
key: ValueKey(index),
child: widget.addSemanticIndexes
? IndexedSemantics(
index: index, child: widget.itemBuilder(context, index))
: widget.itemBuilder(context, index),
);
}
EdgeInsets get _leadingSliverPadding =>
(widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(top: 0)
: widget.padding?.copyWith(bottom: 0)
: widget.reverse
? widget.padding?.copyWith(left: 0)
: widget.padding?.copyWith(right: 0)) ??
EdgeInsets.all(0);
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(
top: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.top
: 0,
bottom: widget.positionedIndex == 0
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
bottom: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.bottom
: 0) ??
EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(
left: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.left
: 0,
right: widget.positionedIndex == 0
? widget.padding!.right
: 0) ??
EdgeInsets.all(0)
: widget.padding?.copyWith(
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
right: widget.positionedIndex == widget.itemCount - 1
? widget.padding!.right
: 0,
) ??
EdgeInsets.all(0);
EdgeInsets get _trailingSliverPadding =>
widget.scrollDirection == Axis.vertical
? widget.reverse
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0)
: widget.reverse
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0)
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0);
void _schedulePositionNotificationUpdate() {
if (!updateScheduled) {
updateScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
final elements = registeredElements.value;
if (elements == null) {
updateScheduled = false;
return;
}
final positions = <ItemPosition>[];
RenderViewportBase? viewport;
for (var element in elements) {
final RenderBox box = element.renderObject as RenderBox;
viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?;
var anchor = 0.0;
if (viewport is RenderViewport) {
anchor = viewport.anchor;
}
if (viewport is CustomRenderViewport) {
anchor = viewport.anchor;
}
final ValueKey<int> key = element.widget.key as ValueKey<int>;
// Skip this element if `box` has never been laid out.
if (!box.hasSize) continue;
if (widget.scrollDirection == Axis.vertical) {
final reveal = viewport!.getOffsetToReveal(box, 0).offset;
if (!reveal.isFinite) continue;
final itemOffset =
reveal - viewport.offset.pixels + anchor * viewport.size.height;
positions.add(ItemPosition(
index: key.value,
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;
if (!itemOffset.isFinite) continue;
positions.add(ItemPosition(
index: key.value,
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;
});
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
/// Widget whose [Element] calls a callback when the element is mounted.
class PostMountCallback extends StatelessWidget {
/// Creates a [PostMountCallback] widget.
const PostMountCallback({required this.child, this.callback, Key? key})
: super(key: key);
/// The widget below this widget in the tree.
final Widget child;
/// Callback to call when the element for this widget is mounted.
final void Function()? callback;
@override
StatelessElement createElement() => _PostMountCallbackElement(this);
@override
Widget build(BuildContext context) => child;
}
class _PostMountCallbackElement extends StatelessElement {
_PostMountCallbackElement(PostMountCallback widget) : super(widget);
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
final PostMountCallback postMountCallback = widget as PostMountCallback;
postMountCallback.callback?.call();
}
}

View File

@@ -0,0 +1,21 @@
import 'dart:async';
import 'scroll_offset_notifier.dart';
/// Provides an affordance for listening to scroll offset changes.
///
/// This is an experimental API and is subject to change.
/// Behavior may be ill-defined in some cases. Please file bugs.
abstract class ScrollOffsetListener {
/// Stream of scroll offset deltas.
Stream<double> get changes;
/// Construct a ScrollOffsetListener.
///
/// Set [recordProgrammaticScrolls] to false to prevent reporting of
/// programmatic scrolls.
factory ScrollOffsetListener.create(
{bool recordProgrammaticScrolls = true}) =>
ScrollOffsetNotifier(
recordProgrammaticScrolls: recordProgrammaticScrolls);
}

View File

@@ -0,0 +1,20 @@
import 'dart:async';
import 'scroll_offset_listener.dart';
class ScrollOffsetNotifier implements ScrollOffsetListener {
final bool recordProgrammaticScrolls;
ScrollOffsetNotifier({this.recordProgrammaticScrolls = true});
final _streamController = StreamController<double>();
@override
Stream<double> get changes => _streamController.stream;
StreamController get changeController => _streamController;
void dispose() {
_streamController.close();
}
}

View File

@@ -0,0 +1,83 @@
// 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 'wrapping.dart';
import 'viewport.dart';
/// A version of [CustomScrollView] that allows does not constrict the extents
/// to be within 0 and 1. See [CustomScrollView] for more information.
class UnboundedCustomScrollView extends CustomScrollView {
final bool _shrinkWrap;
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,
}) : _shrinkWrap = shrinkWrap,
_anchor = anchor,
super(
key: key,
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: false,
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 CustomShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
);
}
return UnboundedViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
);
}
}

View File

@@ -0,0 +1,672 @@
// 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/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'item_positions_notifier.dart';
import 'positioned_list.dart';
import 'post_mount_callback.dart';
import 'scroll_offset_notifier.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.
///
/// The [scrollOffsetListener] can be used to get updates about scroll position
/// changes.
///
/// 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,
this.shrinkWrap = false,
ItemPositionsListener? itemPositionsListener,
this.scrollOffsetController,
ScrollOffsetListener? scrollOffsetListener,
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,
}) : assert(itemCount != null),
assert(itemBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
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.shrinkWrap = false,
this.itemScrollController,
ItemPositionsListener? itemPositionsListener,
this.scrollOffsetController,
ScrollOffsetListener? scrollOffsetListener,
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,
}) : assert(itemCount != null),
assert(itemBuilder != null),
assert(separatorBuilder != null),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
super(key: key);
/// 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;
final ScrollOffsetController? scrollOffsetController;
/// Notifier that reports the changes to the scroll offset.
final ScrollOffsetNotifier? scrollOffsetNotifier;
/// 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;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// Defaults to false.
///
/// See [ScrollView.shrinkWrap].
final bool shrinkWrap;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// See [ScrollView.physics].
final ScrollPhysics? physics;
/// The number of children that will contribute semantic information.
///
/// See [ScrollView.semanticChildCount] for more information.
final int? semanticChildCount;
/// The amount of space by which to inset the children.
final EdgeInsets? padding;
/// Whether to wrap each child in an [IndexedSemantics].
///
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
final bool addSemanticIndexes;
/// Whether to wrap each child in an [AutomaticKeepAlive].
///
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives;
/// Whether to wrap each child in a [RepaintBoundary].
///
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
final bool addRepaintBoundaries;
/// The minimum cache extent used by the underlying scroll lists.
/// See [ScrollView.cacheExtent].
///
/// Note that the [ScrollablePositionedList] uses two lists to simulate long
/// scrolls, so using the [ScrollController.scrollTo] method may result
/// in builds of widgets that would otherwise already be built in the
/// cache extent.
final double? minCacheExtent;
@override
State<StatefulWidget> createState() => _ScrollablePositionedListState();
}
/// Controller to jump or scroll to a particular position in a
/// [ScrollablePositionedList].
class ItemScrollController {
/// Whether any ScrollablePositionedList objects are attached this object.
///
/// If `false`, then [jumpTo] and [scrollTo] must not be called.
bool get isAttached => _scrollableListState != null;
_ScrollablePositionedListState? _scrollableListState;
/// Immediately, without animation, reconfigure the list so that the item at
/// [index]'s leading edge is at the given [alignment].
///
/// The [alignment] specifies the desired position for the leading edge of the
/// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\]
/// and represents a proportion along the main axis of the viewport.
///
/// For a vertically scrolling view that is not reversed:
/// * 0 aligns the top edge of the item with the top edge of the view.
/// * 1 aligns the top edge of the item with the bottom of the view.
/// * 0.5 aligns the top edge of the item with the center of the view.
///
/// For a horizontally scrolling view that is not reversed:
/// * 0 aligns the left edge of the item with the left edge of the view
/// * 1 aligns the left edge of the item with the right edge of the view.
/// * 0.5 aligns the left edge of the item with the center of the view.
void jumpTo({required int index, double alignment = 0}) {
_scrollableListState!._jumpTo(index: index, alignment: alignment);
}
/// Animate the list over [duration] using the given [curve] such that the
/// item at [index] ends up with its leading edge at the given [alignment].
/// See [jumpTo] for an explanation of alignment.
///
/// The [duration] must be greater than 0; otherwise, use [jumpTo].
///
/// When item position is not available, because it's too far, the scroll
/// is composed into three phases:
///
/// 1. The currently displayed list view starts scrolling.
/// 2. Another list view, which scrolls with the same speed, fades over the
/// first one and shows items that are close to the scroll target.
/// 3. The second list view scrolls and stops on the target.
///
/// The [opacityAnimationWeights] can be used to apply custom weights to these
/// three stages of this animation. The default weights, `[40, 20, 40]`, are
/// good with default [Curves.linear]. Different weights might be better for
/// other cases. For example, if you use [Curves.easeOut], consider setting
/// [opacityAnimationWeights] to `[20, 20, 60]`.
///
/// See [TweenSequenceItem.weight] for more info.
Future<void> scrollTo({
required int index,
double alignment = 0,
required Duration duration,
Curve curve = Curves.linear,
List<double> opacityAnimationWeights = const [40, 20, 40],
}) {
assert(_scrollableListState != null);
assert(opacityAnimationWeights.length == 3);
assert(duration > Duration.zero);
return _scrollableListState!._scrollTo(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
}
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(_scrollableListState == null);
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
/// Controller to scroll a certain number of pixels relative to the current
/// scroll offset.
///
/// Scrolls [offset] pixels relative to the current scroll offset. [offset] can
/// be positive or negative.
///
/// This is an experimental API and is subject to change.
/// Behavior may be ill-defined in some cases. Please file bugs.
class ScrollOffsetController {
Future<void> animateScroll(
{required double offset,
required Duration duration,
Curve curve = Curves.linear}) async {
final currentPosition =
_scrollableListState!.primary.scrollController.offset;
final newPosition = currentPosition + offset;
await _scrollableListState!.primary.scrollController.animateTo(
newPosition,
duration: duration,
curve: curve,
);
}
_ScrollablePositionedListState? _scrollableListState;
void _attach(_ScrollablePositionedListState scrollableListState) {
assert(_scrollableListState == null);
_scrollableListState = scrollableListState;
}
void _detach() {
_scrollableListState = null;
}
}
class _ScrollablePositionedListState extends State<ScrollablePositionedList>
with TickerProviderStateMixin {
/// Details for the primary (active) [ListView].
var primary = _ListDisplayDetails(const ValueKey('Ping'));
/// Details for the secondary (transitional) [ListView] that is temporarily
/// shown when scrolling a long distance.
var secondary = _ListDisplayDetails(const ValueKey('Pong'));
final opacity = ProxyAnimation(const AlwaysStoppedAnimation<double>(0));
void Function() startAnimationCallback = () {};
bool _isTransitioning = false;
var _animationController;
double previousOffset = 0;
@override
void initState() {
super.initState();
ItemPosition? initialPosition = PageStorage.of(context).readState(context);
primary.target = initialPosition?.index ?? widget.initialScrollIndex;
primary.alignment =
initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) {
primary.target = widget.itemCount - 1;
}
widget.itemScrollController?._attach(this);
widget.scrollOffsetController?._attach(this);
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
primary.scrollController.addListener(() {
final currentOffset = primary.scrollController.offset;
final offsetChange = currentOffset - previousOffset;
previousOffset = currentOffset;
if (!_isTransitioning |
(widget.scrollOffsetNotifier?.recordProgrammaticScrolls ?? false)) {
widget.scrollOffsetNotifier?.changeController.add(offsetChange);
}
});
}
@override
void activate() {
super.activate();
widget.itemScrollController?._attach(this);
widget.scrollOffsetController?._attach(this);
}
@override
void deactivate() {
widget.itemScrollController?._detach();
widget.scrollOffsetController?._detach();
super.deactivate();
}
@override
void dispose() {
primary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
secondary.itemPositionsNotifier.itemPositions
.removeListener(_updatePositions);
_animationController?.dispose();
super.dispose();
}
@override
void didUpdateWidget(ScrollablePositionedList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemScrollController?._scrollableListState == this) {
oldWidget.itemScrollController?._detach();
}
if (widget.itemScrollController?._scrollableListState != this) {
widget.itemScrollController?._detach();
widget.itemScrollController?._attach(this);
}
if (widget.itemCount == 0) {
setState(() {
primary.target = 0;
secondary.target = 0;
});
} else {
if (primary.target > widget.itemCount - 1) {
setState(() {
primary.target = widget.itemCount - 1;
});
}
if (secondary.target > widget.itemCount - 1) {
setState(() {
secondary.target = widget.itemCount - 1;
});
}
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final cacheExtent = _cacheExtent(constraints);
return Listener(
onPointerDown: (_) => _stopScroll(canceled: 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,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
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,
shrinkWrap: widget.shrinkWrap,
addSemanticIndexes: widget.addSemanticIndexes,
semanticChildCount: widget.semanticChildCount,
padding: widget.padding,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
),
),
),
),
],
),
);
},
);
}
double _cacheExtent(BoxConstraints constraints) => max(
(widget.scrollDirection == Axis.vertical
? constraints.maxHeight
: constraints.maxWidth) *
_screenScrollCount,
widget.minCacheExtent ?? 0,
);
void _jumpTo({required int index, required double alignment}) {
_stopScroll(canceled: true);
if (index > widget.itemCount - 1) {
index = widget.itemCount - 1;
}
setState(() {
primary.scrollController.jumpTo(0);
primary.target = index;
primary.alignment = alignment;
});
}
Future<void> _scrollTo({
required int index,
required double alignment,
required Duration duration,
Curve curve = Curves.linear,
required List<double> opacityAnimationWeights,
}) async {
if (index > widget.itemCount - 1) {
index = widget.itemCount - 1;
}
if (_isTransitioning) {
final scrollCompleter = Completer<void>();
_stopScroll(canceled: true);
SchedulerBinding.instance.addPostFrameCallback((_) async {
await _startScroll(
index: index,
alignment: alignment,
duration: duration,
curve: curve,
opacityAnimationWeights: opacityAnimationWeights,
);
scrollCompleter.complete();
});
await scrollCompleter.future;
} else {
await _startScroll(
index: index,
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 = () {};
_animationController?.dispose();
_animationController =
AnimationController(vsync: this, duration: duration)..forward();
opacity.parent = _opacityAnimation(opacityAnimationWeights)
.animate(_animationController);
secondary.scrollController.jumpTo(-direction *
(_screenScrollCount *
primary.scrollController.position.viewportDimension -
alignment *
secondary.scrollController.position.viewportDimension));
startCompleter.complete(primary.scrollController.animateTo(
primary.scrollController.offset + direction * scrollAmount,
duration: duration,
curve: curve));
endCompleter.complete(secondary.scrollController
.animateTo(0, duration: duration, curve: curve));
});
};
setState(() {
// TODO: _startScroll can be re-entrant, which invalidates this assert.
// assert(!_isTransitioning);
secondary.target = index;
secondary.alignment = alignment;
_isTransitioning = true;
});
await Future.wait<void>([startCompleter.future, endCompleter.future]);
_stopScroll();
}
}
void _stopScroll({bool canceled = false}) {
if (!_isTransitioning) {
return;
}
if (canceled) {
if (primary.scrollController.hasClients) {
primary.scrollController.jumpTo(primary.scrollController.offset);
}
if (secondary.scrollController.hasClients) {
secondary.scrollController.jumpTo(secondary.scrollController.offset);
}
}
if (mounted) {
setState(() {
if (opacity.value >= 0.5) {
// Secondary [ListView] is more visible than the primary; make it the
// new primary.
var temp = primary;
primary = secondary;
secondary = temp;
}
_isTransitioning = false;
opacity.parent = const AlwaysStoppedAnimation<double>(0);
});
}
}
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
final startOpacity = 0.0;
final endOpacity = 1.0;
return TweenSequence<double>(<TweenSequenceItem<double>>[
TweenSequenceItem<double>(
tween: ConstantTween<double>(startOpacity),
weight: opacityAnimationWeights[0]),
TweenSequenceItem<double>(
tween: Tween<double>(begin: startOpacity, end: endOpacity),
weight: opacityAnimationWeights[1]),
TweenSequenceItem<double>(
tween: ConstantTween<double>(endOpacity),
weight: opacityAnimationWeights[2]),
]);
}
void _updatePositions() {
final itemPositions = primary.itemPositionsNotifier.itemPositions.value
.where((ItemPosition position) =>
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0);
if (itemPositions.isNotEmpty) {
PageStorage.of(context).writeState(
context,
itemPositions.reduce((value, element) =>
value.itemLeadingEdge < element.itemLeadingEdge
? value
: element));
}
widget.itemPositionsNotifier?.itemPositions.value = itemPositions;
}
}
class _ListDisplayDetails {
_ListDisplayDetails(this.key);
final itemPositionsNotifier = ItemPositionsNotifier();
final scrollController = ScrollController(keepScrollOffset: false);
/// The index of the item to scroll to.
int target = 0;
/// The desired alignment for [target].
///
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
double alignment = 0;
final Key key;
}

View File

@@ -0,0 +1,315 @@
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A render object that is bigger on the inside.
///
/// Version of [Viewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [Viewport]
/// for more information.
class UnboundedViewport extends Viewport {
UnboundedViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
Key? center,
double? cacheExtent,
List<Widget> slivers = const <Widget>[],
}) : _anchor = anchor,
super(
key: key,
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
slivers: slivers);
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
// version.
final double _anchor;
@override
double get anchor => _anchor;
@override
RenderViewport createRenderObject(BuildContext context) {
return UnboundedRenderViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
anchor: anchor,
offset: offset,
cacheExtent: cacheExtent,
);
}
}
/// A render object that is bigger on the inside.
///
/// Version of [RenderViewport] with some modifications to how extents are
/// computed to allow scroll extents outside 0 to 1. See [RenderViewport]
/// for more information.
///
// Differences from [RenderViewport] are marked with a //***** Differences
// comment.
class UnboundedRenderViewport extends RenderViewport {
/// Creates a viewport for [RenderSliver] objects.
UnboundedRenderViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
}) : _anchor = anchor,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
center: center,
cacheExtent: cacheExtent,
children: children);
static const int _maxLayoutCycles = 10;
double _anchor;
// Out-of-band data computed during layout.
late double _minScrollExtent;
late double _maxScrollExtent;
bool _hasVisualOverflow = false;
/// This value is set during layout based on the [CacheExtentStyle].
///
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
/// of the viewport multiplied by the requested cache extent, which is still
/// expressed in pixels.
double? _calculatedCacheExtent;
@override
double get anchor => _anchor;
@override
set anchor(double value) {
assert(value != null);
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
@override
void performResize() {
super.performResize();
// TODO: Figure out why this override is needed as a result of
// https://github.com/flutter/flutter/pull/61973 and see if it can be
// removed somehow.
switch (axis) {
case Axis.vertical:
offset.applyViewportDimension(size.height);
break;
case Axis.horizontal:
offset.applyViewportDimension(size.width);
break;
}
}
@override
Rect describeSemanticsClip(RenderSliver? child) {
assert(axis != null);
if (_calculatedCacheExtent == null) {
return semanticBounds;
}
switch (axis) {
case Axis.vertical:
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - _calculatedCacheExtent!,
semanticBounds.right,
semanticBounds.bottom + _calculatedCacheExtent!,
);
default:
return Rect.fromLTRB(
semanticBounds.left - _calculatedCacheExtent!,
semanticBounds.top,
semanticBounds.right + _calculatedCacheExtent!,
semanticBounds.bottom,
);
}
}
@override
void performLayout() {
if (center == null) {
assert(firstChild == null);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
assert(center!.parent == this);
late double mainAxisExtent;
late double crossAxisExtent;
switch (axis) {
case Axis.vertical:
mainAxisExtent = size.height;
crossAxisExtent = size.width;
break;
case Axis.horizontal:
mainAxisExtent = size.width;
crossAxisExtent = size.height;
break;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
var count = 0;
do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
final minScrollOffset = math.min(top, maxScrollOffset);
if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset))
break;
// *** End of difference from [RenderViewport].
}
count += 1;
} while (count < _maxLayoutCycles);
assert(() {
if (count >= _maxLayoutCycles) {
assert(count != 1);
throw FlutterError(
'A RenderViewport exceeded its maximum number of layout cycles.\n'
'RenderViewport render objects, during layout, can retry if either their '
'slivers or their ViewportOffset decide that the offset should be corrected '
'to take into account information collected during that layout.\n'
'In the case of this RenderViewport object, however, this happened $count '
'times and still there was no consensus on the scroll offset. This usually '
'indicates a bug. Specifically, it means that one of the following three '
'problems is being experienced by the RenderViewport object:\n'
' * One of the RenderSliver children or the ViewportOffset have a bug such'
' that they always think that they need to correct the offset regardless.\n'
' * Some combination of the RenderSliver children and the ViewportOffset'
' have a bad interaction such that one applies a correction then another'
' applies a reverse correction, leading to an infinite loop of corrections.\n'
' * There is a pathological case that would eventually resolve, but it is'
' so complicated that it cannot be resolved in any reasonable number of'
' layout passes.');
}
return true;
}());
}
double _attemptLayout(
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
assert(!mainAxisExtent.isNaN);
assert(mainAxisExtent >= 0.0);
assert(crossAxisExtent.isFinite);
assert(crossAxisExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final double centerOffset = mainAxisExtent * anchor - correctedOffset;
final double reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final double forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final double reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final double forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final RenderSliver? leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final double result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0.0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0.0, -centerOffset),
overlap:
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
}

View File

@@ -0,0 +1,995 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// A widget that is bigger on the inside and shrink wraps its children in the
/// main axis.
///
/// [ShrinkWrappingViewport] displays a subset of its children according to its
/// own dimensions and the given [offset]. As the offset varies, different
/// children are visible through the viewport.
///
/// [ShrinkWrappingViewport] differs from [Viewport] in that [Viewport] expands
/// to fill the main axis whereas [ShrinkWrappingViewport] sizes itself to match
/// its children in the main axis. This shrink wrapping behavior is expensive
/// because the children, and hence the viewport, could potentially change size
/// whenever the [offset] changes (e.g., because of a collapsing header).
///
/// [ShrinkWrappingViewport] cannot contain box children directly. Instead, use
/// a [SliverList], [SliverFixedExtentList], [SliverGrid], or a
/// [SliverToBoxAdapter], for example.
///
/// See also:
///
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
/// [Scrollable] and [ShrinkWrappingViewport] into widgets that are easier to
/// use.
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
/// sliver context (the opposite of this widget).
/// * [Viewport], a viewport that does not shrink-wrap its contents.
class CustomShrinkWrappingViewport extends CustomViewport {
/// Creates a widget that is bigger on the inside and shrink wraps its
/// children in the main axis.
///
/// The viewport listens to the [offset], which means you do not need to
/// rebuild this widget when the [offset] changes.
///
/// The [offset] argument must not be null.
CustomShrinkWrappingViewport({
Key? key,
AxisDirection axisDirection = AxisDirection.down,
AxisDirection? crossAxisDirection,
double anchor = 0.0,
required ViewportOffset offset,
List<RenderSliver>? children,
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
CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) {
return CustomRenderShrinkWrappingViewport(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
offset: offset,
anchor: anchor,
cacheExtent: cacheExtent,
);
}
@override
void updateRenderObject(
BuildContext context, CustomRenderShrinkWrappingViewport renderObject) {
renderObject
..axisDirection = axisDirection
..crossAxisDirection = crossAxisDirection ??
Viewport.getDefaultCrossAxisDirection(context, axisDirection)
..anchor = anchor
..offset = offset
..cacheExtent = cacheExtent
..cacheExtentStyle = cacheExtentStyle
..clipBehavior = clipBehavior;
}
}
/// A render object that is bigger on the inside and shrink wraps its children
/// in the main axis.
///
/// [RenderShrinkWrappingViewport] displays a subset of its children according
/// to its own dimensions and the given [offset]. As the offset varies, different
/// children are visible through the viewport.
///
/// [RenderShrinkWrappingViewport] differs from [RenderViewport] in that
/// [RenderViewport] expands to fill the main axis whereas
/// [RenderShrinkWrappingViewport] sizes itself to match its children in the
/// main axis. This shrink wrapping behavior is expensive because the children,
/// and hence the viewport, could potentially change size whenever the [offset]
/// changes (e.g., because of a collapsing header).
///
/// [RenderShrinkWrappingViewport] cannot contain [RenderBox] children directly.
/// Instead, use a [RenderSliverList], [RenderSliverFixedExtentList],
/// [RenderSliverGrid], or a [RenderSliverToBoxAdapter], for example.
///
/// See also:
///
/// * [RenderViewport], a viewport that does not shrink-wrap its contents.
/// * [RenderSliver], which explains more about the Sliver protocol.
/// * [RenderBox], which explains more about the Box protocol.
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
/// placed inside a [RenderSliver] (the opposite of this class).
class CustomRenderShrinkWrappingViewport extends CustomRenderViewport {
/// Creates a viewport (for [RenderSliver] objects) that shrink-wraps its
/// contents.
///
/// The [offset] must be specified. For testing purposes, consider passing a
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
CustomRenderShrinkWrappingViewport({
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,
);
double _anchor;
@override
double get anchor => _anchor;
@override
bool get sizedByParent => false;
double lastMainAxisExtent = -1;
@override
set anchor(double value) {
if (value == _anchor) return;
_anchor = value;
markNeedsLayout();
}
late double _shrinkWrapExtent;
/// This value is set during layout based on the [CacheExtentStyle].
///
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
/// of the viewport multiplied by the requested cache extent, which is still
/// expressed in pixels.
double? _calculatedCacheExtent;
/// While List in a wrapping container, eg. ListView,the mainAxisExtent will
/// be infinite. This time need to change mainAxisExtent to this value.
final double _maxMainAxisExtent = double.maxFinite;
@override
void performLayout() {
if (center == null) {
assert(firstChild == null);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
assert(center!.parent == this);
final BoxConstraints constraints = this.constraints;
if (firstChild == null) {
switch (axis) {
case Axis.vertical:
assert(constraints.hasBoundedWidth);
size = Size(constraints.maxWidth, constraints.minHeight);
break;
case Axis.horizontal:
assert(constraints.hasBoundedHeight);
size = Size(constraints.minWidth, constraints.maxHeight);
break;
}
offset.applyViewportDimension(0.0);
_maxScrollExtent = 0.0;
_shrinkWrapExtent = 0.0;
_hasVisualOverflow = false;
offset.applyContentDimensions(0.0, 0.0);
return;
}
double mainAxisExtent;
final double crossAxisExtent;
switch (axis) {
case Axis.vertical:
assert(constraints.hasBoundedWidth);
mainAxisExtent = constraints.maxHeight;
crossAxisExtent = constraints.maxWidth;
break;
case Axis.horizontal:
assert(constraints.hasBoundedHeight);
mainAxisExtent = constraints.maxWidth;
crossAxisExtent = constraints.maxHeight;
break;
}
if (mainAxisExtent.isInfinite) {
mainAxisExtent = _maxMainAxisExtent;
}
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
double correction;
double effectiveExtent;
do {
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
switch (axis) {
case Axis.vertical:
effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent);
break;
case Axis.horizontal:
effectiveExtent = constraints.constrainWidth(_shrinkWrapExtent);
break;
}
// *** Difference from [RenderViewport].
final top = _minScrollExtent + mainAxisExtent * anchor;
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
final minScrollOffset = math.min(top, maxScrollOffset);
final bool didAcceptViewportDimension =
offset.applyViewportDimension(effectiveExtent);
final bool didAcceptContentDimension =
offset.applyContentDimensions(minScrollOffset, maxScrollOffset);
if (didAcceptViewportDimension && didAcceptContentDimension) {
break;
}
}
} while (true);
switch (axis) {
case Axis.vertical:
size =
constraints.constrainDimensions(crossAxisExtent, effectiveExtent);
break;
case Axis.horizontal:
size =
constraints.constrainDimensions(effectiveExtent, crossAxisExtent);
break;
}
}
double _attemptLayout(
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
assert(!mainAxisExtent.isNaN);
assert(mainAxisExtent >= 0.0);
assert(crossAxisExtent.isFinite);
assert(crossAxisExtent >= 0.0);
assert(correctedOffset.isFinite);
_minScrollExtent = 0.0;
_maxScrollExtent = 0.0;
_hasVisualOverflow = false;
_shrinkWrapExtent = 0.0;
// centerOffset is the offset from the leading edge of the RenderViewport
// to the zero scroll offset (the line between the forward slivers and the
// reverse slivers).
final centerOffset = mainAxisExtent * anchor - correctedOffset;
final reverseDirectionRemainingPaintExtent =
centerOffset.clamp(0.0, mainAxisExtent);
final forwardDirectionRemainingPaintExtent =
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
_calculatedCacheExtent = cacheExtent;
break;
case CacheExtentStyle.viewport:
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
break;
}
final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
final centerCacheOffset = centerOffset + _calculatedCacheExtent!;
final reverseDirectionRemainingCacheExtent =
centerCacheOffset.clamp(0.0, fullCacheExtent);
final forwardDirectionRemainingCacheExtent =
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
final leadingNegativeChild = childBefore(center!);
if (leadingNegativeChild != null) {
// negative scroll offsets
final result = layoutChildSequence(
child: leadingNegativeChild,
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
overlap: 0.0,
layoutOffset: forwardDirectionRemainingPaintExtent,
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.reverse,
advance: childBefore,
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
cacheOrigin: (mainAxisExtent - centerOffset)
.clamp(-_calculatedCacheExtent!, 0.0),
);
if (result != 0.0) return -result;
}
// positive scroll offsets
return layoutChildSequence(
child: center,
scrollOffset: math.max(0.0, -centerOffset),
overlap:
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
layoutOffset: centerOffset >= mainAxisExtent
? centerOffset
: reverseDirectionRemainingPaintExtent,
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent,
growthDirection: GrowthDirection.forward,
advance: childAfter,
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
);
}
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
_shrinkWrapExtent += childLayoutGeometry.maxPaintExtent;
growSize = _shrinkWrapExtent;
}
@override
String labelForChild(int index) => 'child $index';
}
/// A widget that is bigger on the inside.
///
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
/// subset of its children according to its own dimensions and the given
/// [offset]. As the offset varies, different children are visible through
/// the viewport.
///
/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center]
/// sliver, which is placed at the zero scroll offset. The center widget is
/// displayed in the viewport according to the [anchor] property.
///
/// Slivers that are earlier in the child list than [center] are displayed in
/// reverse order in the reverse [axisDirection] starting from the [center]. For
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
/// before [center] is placed above the [center]. The slivers that are later in
/// the child list than [center] are placed in order in the [axisDirection]. For
/// example, in the preceding scenario, the first sliver after [center] is
/// placed below the [center].
///
/// [Viewport] cannot contain box children directly. Instead, use a
/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a
/// [SliverToBoxAdapter], for example.
///
/// See also:
///
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
/// [Scrollable] and [Viewport] into widgets that are easier to use.
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
/// sliver context (the opposite of this widget).
/// * [ShrinkWrappingViewport], a variant of [Viewport] that shrink-wraps its
/// contents along the main axis.
abstract class CustomViewport extends MultiChildRenderObjectWidget {
/// Creates a widget that is bigger on the inside.
///
/// The viewport listens to the [offset], which means you do not need to
/// rebuild this widget when the [offset] changes.
///
/// The [offset] argument must not be null.
///
/// The [cacheExtent] must be specified if the [cacheExtentStyle] is
/// not [CacheExtentStyle.pixel].
CustomViewport({
Key? key,
this.axisDirection = AxisDirection.down,
this.crossAxisDirection,
this.anchor = 0.0,
required this.offset,
this.center,
this.cacheExtent,
this.cacheExtentStyle = CacheExtentStyle.pixel,
this.clipBehavior = Clip.hardEdge,
List<Widget> slivers = const <Widget>[],
}) : assert(offset != null),
assert(slivers != null),
assert(center == null ||
slivers.where((Widget child) => child.key == center).length == 1),
assert(cacheExtentStyle != null),
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
cacheExtent != null),
assert(clipBehavior != null),
super(key: key, children: slivers);
/// The direction in which the [offset]'s [ViewportOffset.pixels] increases.
///
/// For example, if the [axisDirection] is [AxisDirection.down], a scroll
/// offset of zero is at the top of the viewport and increases towards the
/// bottom of the viewport.
final AxisDirection axisDirection;
/// The direction in which child should be laid out in the cross axis.
///
/// If the [axisDirection] is [AxisDirection.down] or [AxisDirection.up], this
/// property defaults to [AxisDirection.left] if the ambient [Directionality]
/// is [TextDirection.rtl] and [AxisDirection.right] if the ambient
/// [Directionality] is [TextDirection.ltr].
///
/// If the [axisDirection] is [AxisDirection.left] or [AxisDirection.right],
/// this property defaults to [AxisDirection.down].
final AxisDirection? crossAxisDirection;
/// The relative position of the zero scroll offset.
///
/// For example, if [anchor] is 0.5 and the [axisDirection] is
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
/// on the left edge of the viewport.
final double anchor;
/// Which part of the content inside the viewport should be visible.
///
/// The [ViewportOffset.pixels] value determines the scroll offset that the
/// viewport uses to select which part of its content to display. As the user
/// scrolls the viewport, this value changes, which changes the content that
/// is displayed.
///
/// Typically a [ScrollPosition].
final ViewportOffset offset;
/// The first child in the [GrowthDirection.forward] growth direction.
///
/// Children after [center] will be placed in the [axisDirection] relative to
/// the [center]. Children before [center] will be placed in the opposite of
/// the [axisDirection] relative to the [center].
///
/// The [center] must be the key of a child of the viewport.
final Key? center;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
///
/// See also:
///
/// * [cacheExtentStyle], which controls the units of the [cacheExtent].
final double? cacheExtent;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtentStyle}
final CacheExtentStyle cacheExtentStyle;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// Given a [BuildContext] and an [AxisDirection], determine the correct cross
/// axis direction.
///
/// This depends on the [Directionality] if the `axisDirection` is vertical;
/// otherwise, the default cross axis direction is downwards.
static AxisDirection getDefaultCrossAxisDirection(
BuildContext context, AxisDirection axisDirection) {
assert(axisDirection != null);
switch (axisDirection) {
case AxisDirection.up:
assert(debugCheckHasDirectionality(
context,
why:
'to determine the cross-axis direction when the viewport has an \'up\' axisDirection',
alternative:
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
));
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.right:
return AxisDirection.down;
case AxisDirection.down:
assert(debugCheckHasDirectionality(
context,
why:
'to determine the cross-axis direction when the viewport has a \'down\' axisDirection',
alternative:
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
));
return textDirectionToAxisDirection(Directionality.of(context));
case AxisDirection.left:
return AxisDirection.down;
}
}
@override
CustomRenderViewport createRenderObject(BuildContext context);
@override
_ViewportElement createElement() => _ViewportElement(this);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
properties.add(EnumProperty<AxisDirection>(
'crossAxisDirection', crossAxisDirection,
defaultValue: null));
properties.add(DoubleProperty('anchor', anchor));
properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset));
if (center != null) {
properties.add(DiagnosticsProperty<Key>('center', center));
} else if (children.isNotEmpty && children.first.key != null) {
properties.add(DiagnosticsProperty<Key>('center', children.first.key,
tooltip: 'implicit'));
}
properties.add(DiagnosticsProperty<double>('cacheExtent', cacheExtent));
properties.add(DiagnosticsProperty<CacheExtentStyle>(
'cacheExtentStyle', cacheExtentStyle));
}
}
class _ViewportElement extends MultiChildRenderObjectElement {
/// Creates an element that uses the given widget as its configuration.
_ViewportElement(CustomViewport widget) : super(widget);
@override
CustomViewport get widget => super.widget as CustomViewport;
@override
CustomRenderViewport get renderObject =>
super.renderObject as CustomRenderViewport;
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
_updateCenter();
}
@override
void update(MultiChildRenderObjectWidget newWidget) {
super.update(newWidget);
_updateCenter();
}
void _updateCenter() {
if (widget.center != null) {
renderObject.center = children
.singleWhere((Element element) => element.widget.key == widget.center)
.renderObject as RenderSliver?;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject as RenderSliver?;
} else {
renderObject.center = null;
}
}
@override
void debugVisitOnstageChildren(ElementVisitor visitor) {
children.where((Element e) {
final RenderSliver renderSliver = e.renderObject! as RenderSliver;
return renderSliver.geometry!.visible;
}).forEach(visitor);
}
}
class CustomSliverPhysicalContainerParentData
extends SliverPhysicalContainerParentData {
/// The position of the child relative to the zero scroll offset.
///
/// The number of pixels from from the zero scroll offset of the parent sliver
/// (the line at which its [SliverConstraints.scrollOffset] is zero) to the
/// side of the child closest to that offset. A [layoutOffset] can be null
/// when it cannot be determined. The value will be set after layout.
///
/// In a typical list, this does not change as the parent is scrolled.
///
/// Defaults to null.
double? layoutOffset;
GrowthDirection? growthDirection;
}
/// A render object that is bigger on the inside.
///
/// [RenderViewport] is the visual workhorse of the scrolling machinery. It
/// displays a subset of its children according to its own dimensions and the
/// given [offset]. As the offset varies, different children are visible through
/// the viewport.
///
/// [RenderViewport] hosts a bidirectional list of slivers, anchored on a
/// [center] sliver, which is placed at the zero scroll offset. The center
/// widget is displayed in the viewport according to the [anchor] property.
///
/// Slivers that are earlier in the child list than [center] are displayed in
/// reverse order in the reverse [axisDirection] starting from the [center]. For
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
/// before [center] is placed above the [center]. The slivers that are later in
/// the child list than [center] are placed in order in the [axisDirection]. For
/// example, in the preceding scenario, the first sliver after [center] is
/// placed below the [center].
///
/// [RenderViewport] cannot contain [RenderBox] children directly. Instead, use
/// a [RenderSliverList], [RenderSliverFixedExtentList], [RenderSliverGrid], or
/// a [RenderSliverToBoxAdapter], for example.
///
/// See also:
///
/// * [RenderSliver], which explains more about the Sliver protocol.
/// * [RenderBox], which explains more about the Box protocol.
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
/// placed inside a [RenderSliver] (the opposite of this class).
/// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that
/// shrink-wraps its contents along the main axis.
abstract class CustomRenderViewport
extends RenderViewportBase<CustomSliverPhysicalContainerParentData> {
/// Creates a viewport for [RenderSliver] objects.
///
/// If the [center] is not specified, then the first child in the `children`
/// list, if any, is used.
///
/// The [offset] must be specified. For testing purposes, consider passing a
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
CustomRenderViewport({
AxisDirection axisDirection = AxisDirection.down,
required AxisDirection crossAxisDirection,
required ViewportOffset offset,
double anchor = 0.0,
List<RenderSliver>? children,
RenderSliver? center,
double? cacheExtent,
CacheExtentStyle cacheExtentStyle = CacheExtentStyle.pixel,
Clip clipBehavior = Clip.hardEdge,
}) : assert(anchor != null),
assert(anchor >= 0.0 && anchor <= 1.0),
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
cacheExtent != null),
assert(clipBehavior != null),
_center = center,
super(
axisDirection: axisDirection,
crossAxisDirection: crossAxisDirection,
offset: offset,
cacheExtent: cacheExtent,
cacheExtentStyle: cacheExtentStyle,
clipBehavior: clipBehavior,
) {
addAll(children);
if (center == null && firstChild != null) _center = firstChild;
}
/// If a [RenderAbstractViewport] overrides
/// [RenderObject.describeSemanticsConfiguration] to add the [SemanticsTag]
/// [useTwoPaneSemantics] to its [SemanticsConfiguration], two semantics nodes
/// will be used to represent the viewport with its associated scrolling
/// actions in the semantics tree.
///
/// Two semantics nodes (an inner and an outer node) are necessary to exclude
/// certain child nodes (via the [excludeFromScrolling] tag) from the
/// scrollable area for semantic purposes: The [SemanticsNode]s of children
/// that should be excluded from scrolling will be attached to the outer node.
/// The semantic scrolling actions and the [SemanticsNode]s of scrollable
/// children will be attached to the inner node, which itself is a child of
/// the outer node.
///
/// See also:
///
/// * [RenderViewportBase.describeSemanticsConfiguration], which adds this
/// tag to its [SemanticsConfiguration].
static const SemanticsTag useTwoPaneSemantics =
SemanticsTag('RenderViewport.twoPane');
/// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is
/// tagged with [excludeFromScrolling] it will not be part of the scrolling
/// area for semantic purposes.
///
/// This behavior is only active if the [RenderAbstractViewport]
/// tagged its [SemanticsConfiguration] with [useTwoPaneSemantics].
/// Otherwise, the [excludeFromScrolling] tag is ignored.
///
/// As an example, a [RenderSliver] that stays on the screen within a
/// [Scrollable] even though the user has scrolled past it (e.g. a pinned app
/// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate
/// that it should no longer be considered for semantic actions related to
/// scrolling.
static const SemanticsTag excludeFromScrolling =
SemanticsTag('RenderViewport.excludeFromScrolling');
@override
void setupParentData(RenderObject child) {
if (child.parentData is! CustomSliverPhysicalContainerParentData)
child.parentData = CustomSliverPhysicalContainerParentData();
}
/// The relative position of the zero scroll offset.
///
/// For example, if [anchor] is 0.5 and the [axisDirection] is
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
/// on the left edge of the viewport.
double get anchor;
set anchor(double value);
/// The first child in the [GrowthDirection.forward] growth direction.
///
/// This child that will be at the position defined by [anchor] when the
/// [ViewportOffset.pixels] of [offset] is `0`.
///
/// Children after [center] will be placed in the [axisDirection] relative to
/// the [center]. Children before [center] will be placed in the opposite of
/// the [axisDirection] relative to the [center].
///
/// The [center] must be a child of the viewport.
RenderSliver? get center => _center;
RenderSliver? _center;
set center(RenderSliver? value) {
if (value == _center) return;
_center = value;
markNeedsLayout();
}
@override
bool get sizedByParent => true;
@override
Size computeDryLayout(BoxConstraints constraints) {
assert(() {
if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) {
switch (axis) {
case Axis.vertical:
if (!constraints.hasBoundedHeight) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Vertical viewport was given unbounded height.'),
ErrorDescription(
'Viewports expand in the scrolling direction to fill their container. '
'In this case, a vertical viewport was given an unlimited amount of '
'vertical space in which to expand. This situation typically happens '
'when a scrollable widget is nested inside another scrollable widget.'),
ErrorHint(
'If this widget is always nested in a scrollable widget there '
'is no need to use a viewport because there will always be enough '
'vertical space for the children. In this case, consider using a '
'Column instead. Otherwise, consider using the "shrinkWrap" property '
'(or a ShrinkWrappingViewport) to size the height of the viewport '
'to the sum of the heights of its children.')
]);
}
if (!constraints.hasBoundedWidth) {
throw FlutterError(
'Vertical viewport was given unbounded width.\n'
'Viewports expand in the cross axis to fill their container and '
'constrain their children to match their extent in the cross axis. '
'In this case, a vertical viewport was given an unlimited amount of '
'horizontal space in which to expand.');
}
break;
case Axis.horizontal:
if (!constraints.hasBoundedWidth) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Horizontal viewport was given unbounded width.'),
ErrorDescription(
'Viewports expand in the scrolling direction to fill their container. '
'In this case, a horizontal viewport was given an unlimited amount of '
'horizontal space in which to expand. This situation typically happens '
'when a scrollable widget is nested inside another scrollable widget.'),
ErrorHint(
'If this widget is always nested in a scrollable widget there '
'is no need to use a viewport because there will always be enough '
'horizontal space for the children. In this case, consider using a '
'Row instead. Otherwise, consider using the "shrinkWrap" property '
'(or a ShrinkWrappingViewport) to size the width of the viewport '
'to the sum of the widths of its children.')
]);
}
if (!constraints.hasBoundedHeight) {
throw FlutterError(
'Horizontal viewport was given unbounded height.\n'
'Viewports expand in the cross axis to fill their container and '
'constrain their children to match their extent in the cross axis. '
'In this case, a horizontal viewport was given an unlimited amount of '
'vertical space in which to expand.');
}
break;
}
}
return true;
}());
return constraints.biggest;
}
// Out-of-band data computed during layout.
late double _minScrollExtent;
late double _maxScrollExtent;
bool _hasVisualOverflow = false;
double growSize = 0;
@override
bool get hasVisualOverflow => _hasVisualOverflow;
@override
void updateOutOfBandData(
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
switch (growthDirection) {
case GrowthDirection.forward:
_maxScrollExtent += childLayoutGeometry.scrollExtent;
break;
case GrowthDirection.reverse:
_minScrollExtent -= childLayoutGeometry.scrollExtent;
break;
}
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
}
@override
void updateChildLayoutOffset(RenderSliver child, double layoutOffset,
GrowthDirection growthDirection) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
childParentData.layoutOffset = layoutOffset;
childParentData.growthDirection = growthDirection;
}
@override
Offset paintOffsetOf(RenderSliver child) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
return computeAbsolutePaintOffset(
child, childParentData.layoutOffset!, childParentData.growthDirection!);
}
@override
double scrollOffsetOf(RenderSliver child, double scrollOffsetWithinChild) {
assert(child.parent == this);
final GrowthDirection growthDirection = child.constraints.growthDirection;
switch (growthDirection) {
case GrowthDirection.forward:
double scrollOffsetToChild = 0.0;
RenderSliver? current = center;
while (current != child) {
scrollOffsetToChild += current!.geometry!.scrollExtent;
current = childAfter(current);
}
return scrollOffsetToChild + scrollOffsetWithinChild;
case GrowthDirection.reverse:
double scrollOffsetToChild = 0.0;
RenderSliver? current = childBefore(center!);
while (current != child) {
scrollOffsetToChild -= current!.geometry!.scrollExtent;
current = childBefore(current);
}
return scrollOffsetToChild - scrollOffsetWithinChild;
}
}
@override
double maxScrollObstructionExtentBefore(RenderSliver child) {
assert(child.parent == this);
final GrowthDirection growthDirection = child.constraints.growthDirection;
switch (growthDirection) {
case GrowthDirection.forward:
double pinnedExtent = 0.0;
RenderSliver? current = center;
while (current != child) {
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
current = childAfter(current);
}
return pinnedExtent;
case GrowthDirection.reverse:
double pinnedExtent = 0.0;
RenderSliver? current = childBefore(center!);
while (current != child) {
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
current = childBefore(current);
}
return pinnedExtent;
}
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
final Offset offset = paintOffsetOf(child as RenderSliver);
transform.translate(offset.dx, offset.dy);
}
@override
double computeChildMainAxisPosition(
RenderSliver child, double parentMainAxisPosition) {
final CustomSliverPhysicalContainerParentData childParentData =
child.parentData! as CustomSliverPhysicalContainerParentData;
switch (applyGrowthDirectionToAxisDirection(
child.constraints.axisDirection, child.constraints.growthDirection)) {
case AxisDirection.down:
case AxisDirection.right:
return parentMainAxisPosition - childParentData.layoutOffset!;
case AxisDirection.up:
return (size.height - parentMainAxisPosition) -
childParentData.layoutOffset!;
case AxisDirection.left:
return (size.width - parentMainAxisPosition) -
childParentData.layoutOffset!;
}
}
@override
int get indexOfFirstChild {
assert(center != null);
assert(center!.parent == this);
assert(firstChild != null);
int count = 0;
RenderSliver? child = center;
while (child != firstChild) {
count -= 1;
child = childBefore(child!);
}
return count;
}
@override
String labelForChild(int index) {
if (index == 0) return 'center child';
return 'child $index';
}
@override
Iterable<RenderSliver> get childrenInPaintOrder sync* {
if (firstChild == null) return;
RenderSliver? child = firstChild;
while (child != center) {
yield child!;
child = childAfter(child);
}
child = lastChild;
while (true) {
yield child!;
if (child == center) return;
child = childBefore(child);
}
}
@override
Iterable<RenderSliver> get childrenInHitTestOrder sync* {
if (firstChild == null) return;
RenderSliver? child = center;
while (child != null) {
yield child;
child = childAfter(child);
}
child = childBefore(center!);
while (child != null) {
yield child;
child = childBefore(child);
}
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('anchor', anchor));
}
}

View File

@@ -0,0 +1,19 @@
name: scrollable_positioned_list
version: 0.3.8
description: >
A list with helper methods to programmatically scroll to an item.
homepage: https://github.com/google/flutter.widgets/tree/master/packages/scrollable_positioned_list
environment:
sdk: '>=2.15.0 <4.0.0'
flutter: '>=2.12.0'
dependencies:
flutter:
sdk: flutter
collection: ^1.15.0
dev_dependencies:
pedantic: ^1.10.0-0
flutter_test:
sdk: flutter