diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 94ceef8c7..0fa366257 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -48,13 +48,6 @@ dev_dependencies: flutter_test: sdk: flutter -dependency_overrides: - scrollable_positioned_list: - git: - url: https://github.com/LucasXu0/flutter.widgets.git - ref: 93d78cbcd689a89a8fa6deb87eeb70dc90cc7700 - path: packages/scrollable_positioned_list - # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/lib/src/editor/editor_component/entry/page_block_component.dart b/lib/src/editor/editor_component/entry/page_block_component.dart index 0cd7ab750..303300220 100644 --- a/lib/src/editor/editor_component/entry/page_block_component.dart +++ b/lib/src/editor/editor_component/entry/page_block_component.dart @@ -1,7 +1,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class PageBlockKeys { static const String type = 'page'; diff --git a/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart b/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart index b113d6cea..8005572e3 100644 --- a/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart +++ b/lib/src/editor/editor_component/service/scroll/editor_scroll_controller.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'dart:math'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flutter/material.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; /// This class controls the scroll behavior of the editor. /// diff --git a/lib/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart b/lib/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart new file mode 100644 index 000000000..fe985e62a --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart @@ -0,0 +1,7 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/item_positions_listener.dart'; +export 'src/scrollable_positioned_list.dart'; +export 'src/scroll_offset_listener.dart'; diff --git a/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart b/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart new file mode 100644 index 000000000..886d1fb55 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/element_registry.dart @@ -0,0 +1,98 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// A registry to track some [Element]s in the tree. +class RegistryWidget extends StatefulWidget { + /// Creates a [RegistryWidget]. + const RegistryWidget({Key? key, this.elementNotifier, required this.child}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Contains the current set of all [Element]s created by + /// [RegisteredElementWidget]s in the tree below this widget. + /// + /// Note that if there is another [RegistryWidget] in this widget's subtree + /// that registry, and not this one, will collect elements in its subtree. + final ValueNotifier?>? elementNotifier; + + @override + State createState() => _RegistryWidgetState(); +} + +/// A widget whose [Element] will be added its nearest ancestor +/// [RegistryWidget]. +class RegisteredElementWidget extends ProxyWidget { + /// Creates a [RegisteredElementWidget]. + const RegisteredElementWidget({Key? key, required Widget child}) + : super(key: key, child: child); + + @override + Element createElement() => _RegisteredElement(this); +} + +class _RegistryWidgetState extends State { + final Set registeredElements = {}; + + @override + Widget build(BuildContext context) => _InheritedRegistryWidget( + state: this, + child: widget.child, + ); +} + +class _InheritedRegistryWidget extends InheritedWidget { + final _RegistryWidgetState state; + + const _InheritedRegistryWidget({ + Key? key, + required this.state, + required Widget child, + }) : super(key: key, child: child); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) => true; +} + +class _RegisteredElement extends ProxyElement { + _RegisteredElement(ProxyWidget widget) : super(widget); + + @override + void notifyClients(ProxyWidget oldWidget) {} + + late _RegistryWidgetState _registryWidgetState; + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final inheritedRegistryWidget = + dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + _registryWidgetState = inheritedRegistryWidget.state; + _registryWidgetState.registeredElements.add(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + } + + @override + void unmount() { + _registryWidgetState.registeredElements.remove(this); + _registryWidgetState.widget.elementNotifier?.value = + _registryWidgetState.registeredElements; + super.unmount(); + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/item_positions_listener.dart b/lib/src/flutter/scrollable_positioned_list/src/item_positions_listener.dart new file mode 100644 index 000000000..ae11e3182 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/item_positions_listener.dart @@ -0,0 +1,62 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'item_positions_notifier.dart'; +import 'scrollable_positioned_list.dart'; + +/// Provides a listenable iterable of [itemPositions] of items that are on +/// screen and their locations. +abstract class ItemPositionsListener { + /// Creates an [ItemPositionsListener] that can be used by a + /// [ScrollablePositionedList] to return the current position of items. + factory ItemPositionsListener.create() => ItemPositionsNotifier(); + + /// The position of items that are at least partially visible in the viewport. + ValueListenable> get itemPositions; +} + +/// Position information for an item in the list. +class ItemPosition { + /// Create an [ItemPosition]. + const ItemPosition({ + required this.index, + required this.itemLeadingEdge, + required this.itemTrailingEdge, + }); + + /// Index of the item. + final int index; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the leading edge of the item. + /// + /// May be negative if the item is partially visible. + final double itemLeadingEdge; + + /// Distance in proportion of the viewport's main axis length from the leading + /// edge of the viewport to the trailing edge of the item. + /// + /// May be greater than one if the item is partially visible. + final double itemTrailingEdge; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final ItemPosition otherPosition = other; + return otherPosition.index == index && + otherPosition.itemLeadingEdge == itemLeadingEdge && + otherPosition.itemTrailingEdge == itemTrailingEdge; + } + + @override + int get hashCode => + 31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) + + itemTrailingEdge.hashCode; + + @override + String toString() => + 'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'; +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/item_positions_notifier.dart b/lib/src/flutter/scrollable_positioned_list/src/item_positions_notifier.dart new file mode 100644 index 000000000..a2b993a64 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/item_positions_notifier.dart @@ -0,0 +1,13 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; + +import 'item_positions_listener.dart'; + +/// Internal implementation of [ItemPositionsListener]. +class ItemPositionsNotifier implements ItemPositionsListener { + @override + final ValueNotifier> itemPositions = ValueNotifier([]); +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart b/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart new file mode 100644 index 000000000..5a38fb5c8 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/positioned_list.dart @@ -0,0 +1,388 @@ +// 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((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 createState() => _PositionedListState(); +} + +class _PositionedListState extends State { + final Key _centerKey = UniqueKey(); + + final registeredElements = ValueNotifier?>(null); + late final ScrollController scrollController; + + bool updateScheduled = false; + + @override + void initState() { + super.initState(); + scrollController = widget.controller ?? ScrollController(); + scrollController.addListener(_schedulePositionNotificationUpdate); + _schedulePositionNotificationUpdate(); + } + + @override + void dispose() { + scrollController.removeListener(_schedulePositionNotificationUpdate); + super.dispose(); + } + + @override + void didUpdateWidget(PositionedList oldWidget) { + super.didUpdateWidget(oldWidget); + _schedulePositionNotificationUpdate(); + } + + @override + Widget build(BuildContext context) => RegistryWidget( + elementNotifier: registeredElements, + child: UnboundedCustomScrollView( + anchor: widget.alignment, + center: _centerKey, + controller: scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + slivers: [ + if (widget.positionedIndex > 0) + SliverPadding( + padding: _leadingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem( + context, + widget.positionedIndex - (index + 1), + ) + : _buildSeparatedListElement( + context, + 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(context, index + widget.positionedIndex) + : _buildSeparatedListElement( + context, + 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( + context, + index + widget.positionedIndex + 1, + ) + : _buildSeparatedListElement( + context, + 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(BuildContext context, int index) { + if (index.isEven) { + return _buildItem(context, index ~/ 2); + } else { + return widget.separatorBuilder!(context, index ~/ 2); + } + } + + Widget _buildItem(BuildContext context, 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)) ?? + const EdgeInsets.all(0); + + EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith( + top: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.top + : 0, + bottom: + widget.positionedIndex == 0 ? widget.padding!.bottom : 0, + ) ?? + const EdgeInsets.all(0) + : widget.padding?.copyWith( + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.bottom + : 0, + ) ?? + const EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith( + left: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.left + : 0, + right: widget.positionedIndex == 0 ? widget.padding!.right : 0, + ) ?? + const EdgeInsets.all(0) + : widget.padding?.copyWith( + left: widget.positionedIndex == 0 ? widget.padding!.left : 0, + right: widget.positionedIndex == widget.itemCount - 1 + ? widget.padding!.right + : 0, + ) ?? + const EdgeInsets.all(0); + + EdgeInsets get _trailingSliverPadding => + widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(bottom: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(top: 0) ?? const EdgeInsets.all(0) + : widget.reverse + ? widget.padding?.copyWith(right: 0) ?? const EdgeInsets.all(0) + : widget.padding?.copyWith(left: 0) ?? const EdgeInsets.all(0); + + void _schedulePositionNotificationUpdate() { + if (!updateScheduled) { + updateScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + final elements = registeredElements.value; + if (elements == null) { + updateScheduled = false; + return; + } + final positions = []; + 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 key = element.widget.key as ValueKey; + // 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; + }); + } + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/post_mount_callback.dart b/lib/src/flutter/scrollable_positioned_list/src/post_mount_callback.dart new file mode 100644 index 000000000..9ec553fa7 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/post_mount_callback.dart @@ -0,0 +1,35 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +/// Widget whose [Element] calls a callback when the element is mounted. +class PostMountCallback extends StatelessWidget { + /// Creates a [PostMountCallback] widget. + const PostMountCallback({required this.child, this.callback, Key? key}) + : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Callback to call when the element for this widget is mounted. + final void Function()? callback; + + @override + StatelessElement createElement() => _PostMountCallbackElement(this); + + @override + Widget build(BuildContext context) => child; +} + +class _PostMountCallbackElement extends StatelessElement { + _PostMountCallbackElement(PostMountCallback widget) : super(widget); + + @override + void mount(Element? parent, dynamic newSlot) { + super.mount(parent, newSlot); + final PostMountCallback postMountCallback = widget as PostMountCallback; + postMountCallback.callback?.call(); + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart new file mode 100644 index 000000000..50bb32696 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_listener.dart @@ -0,0 +1,23 @@ +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 get changes; + + /// Construct a ScrollOffsetListener. + /// + /// Set [recordProgrammaticScrolls] to false to prevent reporting of + /// programmatic scrolls. + factory ScrollOffsetListener.create({ + bool recordProgrammaticScrolls = true, + }) => + ScrollOffsetNotifier( + recordProgrammaticScrolls: recordProgrammaticScrolls, + ); +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart new file mode 100644 index 000000000..871de3bcd --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_offset_notifier.dart @@ -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(); + + @override + Stream get changes => _streamController.stream; + + StreamController get changeController => _streamController; + + void dispose() { + _streamController.close(); + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart b/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart new file mode 100644 index 000000000..31acda407 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/scroll_view.dart @@ -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 slivers = const [], + 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 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, + ); + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart b/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart new file mode 100644 index 000000000..1aba23702 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -0,0 +1,697 @@ +// 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:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.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, + }) : 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(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 createState() => _ScrollablePositionedListState(); +} + +/// Controller to jump or scroll to a particular position in a +/// [ScrollablePositionedList]. +class ItemScrollController { + /// Whether any ScrollablePositionedList objects are attached this object. + /// + /// If `false`, then [jumpTo] and [scrollTo] must not be called. + bool get isAttached => _scrollableListState != null; + + _ScrollablePositionedListState? _scrollableListState; + + /// Immediately, without animation, reconfigure the list so that the item at + /// [index]'s leading edge is at the given [alignment]. + /// + /// The [alignment] specifies the desired position for the leading edge of the + /// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\] + /// and represents a proportion along the main axis of the viewport. + /// + /// For a vertically scrolling view that is not reversed: + /// * 0 aligns the top edge of the item with the top edge of the view. + /// * 1 aligns the top edge of the item with the bottom of the view. + /// * 0.5 aligns the top edge of the item with the center of the view. + /// + /// For a horizontally scrolling view that is not reversed: + /// * 0 aligns the left edge of the item with the left edge of the view + /// * 1 aligns the left edge of the item with the right edge of the view. + /// * 0.5 aligns the left edge of the item with the center of the view. + void jumpTo({required int index, double alignment = 0}) { + _scrollableListState!._jumpTo(index: index, alignment: alignment); + } + + /// Animate the list over [duration] using the given [curve] such that the + /// item at [index] ends up with its leading edge at the given [alignment]. + /// See [jumpTo] for an explanation of alignment. + /// + /// The [duration] must be greater than 0; otherwise, use [jumpTo]. + /// + /// When item position is not available, because it's too far, the scroll + /// is composed into three phases: + /// + /// 1. The currently displayed list view starts scrolling. + /// 2. Another list view, which scrolls with the same speed, fades over the + /// first one and shows items that are close to the scroll target. + /// 3. The second list view scrolls and stops on the target. + /// + /// The [opacityAnimationWeights] can be used to apply custom weights to these + /// three stages of this animation. The default weights, `[40, 20, 40]`, are + /// good with default [Curves.linear]. Different weights might be better for + /// other cases. For example, if you use [Curves.easeOut], consider setting + /// [opacityAnimationWeights] to `[20, 20, 60]`. + /// + /// See [TweenSequenceItem.weight] for more info. + Future scrollTo({ + required int index, + double alignment = 0, + required Duration duration, + Curve curve = Curves.linear, + List opacityAnimationWeights = const [40, 20, 40], + }) { + assert(_scrollableListState != null); + assert(opacityAnimationWeights.length == 3); + assert(duration > Duration.zero); + return _scrollableListState!._scrollTo( + index: index, + alignment: alignment, + duration: duration, + curve: curve, + opacityAnimationWeights: opacityAnimationWeights, + ); + } + + void _attach(_ScrollablePositionedListState scrollableListState) { + assert(_scrollableListState == null); + _scrollableListState = scrollableListState; + } + + void _detach() { + _scrollableListState = null; + } +} + +/// 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 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, + ); + } + + Future animateTo({ + required double offset, + required Duration duration, + Curve curve = Curves.linear, + }) async { + await _scrollableListState!.primary.scrollController.animateTo( + offset, + duration: duration, + curve: curve, + ); + } + + _ScrollablePositionedListState? _scrollableListState; + + void _attach(_ScrollablePositionedListState scrollableListState) { + assert(_scrollableListState == null); + _scrollableListState = scrollableListState; + } + + void _detach() { + _scrollableListState = null; + } +} + +class _ScrollablePositionedListState extends State + with TickerProviderStateMixin { + /// Details for the primary (active) [ListView]. + var primary = _ListDisplayDetails(const ValueKey('Ping')); + + /// Details for the secondary (transitional) [ListView] that is temporarily + /// shown when scrolling a long distance. + var secondary = _ListDisplayDetails(const ValueKey('Pong')); + + final opacity = ProxyAnimation(const AlwaysStoppedAnimation(0)); + + void Function() startAnimationCallback = () {}; + + bool _isTransitioning = false; + + AnimationController? _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); + final child = Listener( + onPointerDown: (_) => _stopScroll(canceled: true), + child: Stack( + children: [ + PostMountCallback( + key: primary.key, + callback: startAnimationCallback, + child: FadeTransition( + opacity: ReverseAnimation(opacity), + child: NotificationListener( + onNotification: (_) => _isTransitioning, + child: PositionedList( + itemBuilder: widget.itemBuilder, + separatorBuilder: widget.separatorBuilder, + itemCount: widget.itemCount, + positionedIndex: primary.target, + controller: primary.scrollController, + itemPositionsNotifier: primary.itemPositionsNotifier, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: cacheExtent, + alignment: primary.alignment, + physics: widget.physics, + 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( + 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, + ), + ), + ), + ), + ], + ), + ); + return child; + }, + ); + } + + 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 _scrollTo({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + if (index > widget.itemCount - 1) { + index = widget.itemCount - 1; + } + if (_isTransitioning) { + final scrollCompleter = Completer(); + _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 _startScroll({ + required int index, + required double alignment, + required Duration duration, + Curve curve = Curves.linear, + required List opacityAnimationWeights, + }) async { + final direction = index > primary.target ? 1 : -1; + final itemPosition = + primary.itemPositionsNotifier.itemPositions.value.firstWhereOrNull( + (ItemPosition itemPosition) => itemPosition.index == index, + ); + if (itemPosition != null) { + // Scroll directly. + final localScrollAmount = itemPosition.itemLeadingEdge * + primary.scrollController.position.viewportDimension; + await primary.scrollController.animateTo( + primary.scrollController.offset + + localScrollAmount - + alignment * primary.scrollController.position.viewportDimension, + duration: duration, + curve: curve, + ); + } else { + final scrollAmount = _screenScrollCount * + primary.scrollController.position.viewportDimension; + final startCompleter = Completer(); + final endCompleter = Completer(); + startAnimationCallback = () { + SchedulerBinding.instance.addPostFrameCallback((_) { + startAnimationCallback = () {}; + _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([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(0); + }); + } + } + + Animatable _opacityAnimation(List opacityAnimationWeights) { + const startOpacity = 0.0; + const endOpacity = 1.0; + return TweenSequence(>[ + TweenSequenceItem( + tween: ConstantTween(startOpacity), + weight: opacityAnimationWeights[0], + ), + TweenSequenceItem( + tween: Tween(begin: startOpacity, end: endOpacity), + weight: opacityAnimationWeights[1], + ), + TweenSequenceItem( + tween: ConstantTween(endOpacity), + weight: opacityAnimationWeights[2], + ), + ]); + } + + void _updatePositions() { + final itemPositions = + primary.itemPositionsNotifier.itemPositions.value.where( + (ItemPosition position) => + position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0, + ); + if (itemPositions.isNotEmpty) { + PageStorage.of(context).writeState( + context, + itemPositions.reduce( + (value, element) => + value.itemLeadingEdge < element.itemLeadingEdge ? value : element, + ), + ); + } + widget.itemPositionsNotifier?.itemPositions.value = itemPositions; + } +} + +class _ListDisplayDetails { + _ListDisplayDetails(this.key); + + final itemPositionsNotifier = ItemPositionsNotifier(); + final scrollController = ScrollController(keepScrollOffset: false); + + /// The index of the item to scroll to. + int target = 0; + + /// The desired alignment for [target]. + /// + /// See [ItemScrollController.jumpTo] for an explanation of alignment. + double alignment = 0; + + final Key key; +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/viewport.dart b/lib/src/flutter/scrollable_positioned_list/src/viewport.dart new file mode 100644 index 000000000..c89a6b81c --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/viewport.dart @@ -0,0 +1,322 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A render object that is bigger on the inside. +/// +/// Version of [Viewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [Viewport] +/// for more information. +class UnboundedViewport extends Viewport { + UnboundedViewport({ + Key? key, + AxisDirection axisDirection = AxisDirection.down, + AxisDirection? crossAxisDirection, + double anchor = 0.0, + required ViewportOffset offset, + Key? center, + double? cacheExtent, + List slivers = const [], + }) : _anchor = anchor, + super( + key: key, + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + slivers: slivers, + ); + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + RenderViewport createRenderObject(BuildContext context) { + return UnboundedRenderViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection ?? + Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + ); + } +} + +/// A render object that is bigger on the inside. +/// +/// Version of [RenderViewport] with some modifications to how extents are +/// computed to allow scroll extents outside 0 to 1. See [RenderViewport] +/// for more information. +/// +// Differences from [RenderViewport] are marked with a //***** Differences +// comment. +class UnboundedRenderViewport extends RenderViewport { + /// Creates a viewport for [RenderSliver] objects. + UnboundedRenderViewport({ + AxisDirection axisDirection = AxisDirection.down, + required AxisDirection crossAxisDirection, + required ViewportOffset offset, + double anchor = 0.0, + List? children, + RenderSliver? center, + double? cacheExtent, + }) : _anchor = anchor, + super( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + children: children, + ); + + static const int _maxLayoutCycles = 10; + + double _anchor; + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + /// This value is set during layout based on the [CacheExtentStyle]. + /// + /// When the style is [CacheExtentStyle.viewport], it is the main axis extent + /// of the viewport multiplied by the requested cache extent, which is still + /// expressed in pixels. + double? _calculatedCacheExtent; + + @override + double get anchor => _anchor; + + @override + set anchor(double value) { + if (value == _anchor) return; + _anchor = value; + markNeedsLayout(); + } + + @override + void performResize() { + super.performResize(); + // TODO: Figure out why this override is needed as a result of + // https://github.com/flutter/flutter/pull/61973 and see if it can be + // removed somehow. + switch (axis) { + case Axis.vertical: + offset.applyViewportDimension(size.height); + break; + case Axis.horizontal: + offset.applyViewportDimension(size.width); + break; + } + } + + @override + Rect describeSemanticsClip(RenderSliver? child) { + if (_calculatedCacheExtent == null) { + return semanticBounds; + } + + switch (axis) { + case Axis.vertical: + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - _calculatedCacheExtent!, + semanticBounds.right, + semanticBounds.bottom + _calculatedCacheExtent!, + ); + default: + return Rect.fromLTRB( + semanticBounds.left - _calculatedCacheExtent!, + semanticBounds.top, + semanticBounds.right + _calculatedCacheExtent!, + semanticBounds.bottom, + ); + } + } + + @override + void performLayout() { + if (center == null) { + assert(firstChild == null); + _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 { + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + // *** Difference from [RenderViewport]. + final top = _minScrollExtent + mainAxisExtent * anchor; + final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor); + final maxScrollOffset = math.max(math.min(0.0, top), bottom); + final minScrollOffset = math.min(top, maxScrollOffset); + if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset)) { + break; + } + // *** End of difference from [RenderViewport]. + } + count += 1; + } while (count < _maxLayoutCycles); + assert(() { + if (count >= _maxLayoutCycles) { + assert(count != 1); + throw FlutterError( + 'A RenderViewport exceeded its maximum number of layout cycles.\n' + 'RenderViewport render objects, during layout, can retry if either their ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.'); + } + return true; + }()); + } + + double _attemptLayout( + double mainAxisExtent, + double crossAxisExtent, + double correctedOffset, + ) { + assert(!mainAxisExtent.isNaN); + assert(mainAxisExtent >= 0.0); + assert(crossAxisExtent.isFinite); + assert(crossAxisExtent >= 0.0); + assert(correctedOffset.isFinite); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + final double centerOffset = mainAxisExtent * anchor - correctedOffset; + final double reverseDirectionRemainingPaintExtent = + centerOffset.clamp(0.0, mainAxisExtent); + final double forwardDirectionRemainingPaintExtent = + (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + + switch (cacheExtentStyle) { + case CacheExtentStyle.pixel: + _calculatedCacheExtent = cacheExtent; + break; + case CacheExtentStyle.viewport: + _calculatedCacheExtent = mainAxisExtent * cacheExtent!; + break; + } + + final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final double reverseDirectionRemainingCacheExtent = + centerCacheOffset.clamp(0.0, fullCacheExtent); + final double forwardDirectionRemainingCacheExtent = + (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + + final RenderSliver? leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final double result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0.0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: (mainAxisExtent - centerOffset) + .clamp(-_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) return -result; + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0.0, -centerOffset), + overlap: + leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0, + layoutOffset: centerOffset >= mainAxisExtent + ? centerOffset + : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData( + GrowthDirection growthDirection, + SliverGeometry childLayoutGeometry, + ) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + break; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + break; + } + if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true; + } +} diff --git a/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart b/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart new file mode 100644 index 000000000..26cb1ce07 --- /dev/null +++ b/lib/src/flutter/scrollable_positioned_list/src/wrapping.dart @@ -0,0 +1,1035 @@ +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? children, + Key? center, + double? cacheExtent, + List slivers = const [], + }) : _anchor = anchor, + super( + key: key, + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection, + offset: offset, + center: center, + cacheExtent: cacheExtent, + slivers: slivers, + ); + + // [Viewport] enforces constraints on [Viewport.anchor], so we need our own + // version. + final double _anchor; + + @override + double get anchor => _anchor; + + @override + 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? 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 slivers = const [], + }) : assert( + center == null || + slivers.where((Widget child) => child.key == center).length == 1, + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != 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, + ) { + 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)); + properties.add( + EnumProperty( + 'crossAxisDirection', + crossAxisDirection, + defaultValue: null, + ), + ); + properties.add(DoubleProperty('anchor', anchor)); + properties.add(DiagnosticsProperty('offset', offset)); + if (center != null) { + properties.add(DiagnosticsProperty('center', center)); + } else if (children.isNotEmpty && children.first.key != null) { + properties.add( + DiagnosticsProperty( + 'center', + children.first.key, + tooltip: 'implicit', + ), + ); + } + properties.add(DiagnosticsProperty('cacheExtent', cacheExtent)); + properties.add( + DiagnosticsProperty( + '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 { + /// 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? children, + RenderSliver? center, + double? cacheExtent, + CacheExtentStyle cacheExtentStyle = CacheExtentStyle.pixel, + Clip clipBehavior = Clip.hardEdge, + }) : assert(anchor >= 0.0 && anchor <= 1.0), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != 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([ + 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([ + 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 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 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)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e1a17dd2d..11d7a76e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,6 @@ dependencies: visibility_detector: ^0.4.0+2 file_picker: ^5.3.1 path: ^1.8.3 - scrollable_positioned_list: ^0.3.8 diff_match_patch: ^0.4.1 dev_dependencies: @@ -46,13 +45,6 @@ dev_dependencies: network_image_mock: ^2.1.1 mockito: ^5.4.1 -dependency_overrides: - scrollable_positioned_list: - git: - url: https://github.com/LucasXu0/flutter.widgets.git - ref: 93d78cbcd689a89a8fa6deb87eeb70dc90cc7700 - path: packages/scrollable_positioned_list - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec