Unverified Commit 0f1a95d1 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

TwoDimensional scrolling foundation (#125437)

From the 2D scrolling proposal: [flutter.dev/go/2D-Foundation](https://flutter.dev/go/2D-Foundation) ✅  updated 4/25

Fixes https://github.com/flutter/flutter/issues/125505

Follow up issues:
- https://github.com/flutter/flutter/issues/126297
- https://github.com/flutter/flutter/issues/126298
- https://github.com/flutter/flutter/issues/126299
-  https://github.com/flutter/flutter/issues/122348

This adds a mostly abstract foundation for 2D scrolling in Flutter.

With these base classes, developers will be able to construct widgets that scroll in both dimensions and can lazily load their children for the best performance. This implementation is meant to be flexible in order to support different kinds of 2D compositions, from tables to scatter plots. 

The upcoming TableView, TreeView, etc widgets (coming soon in flutter/packages) are built on top of this foundation.
parent 15fa5b53
...@@ -9,6 +9,7 @@ import 'automatic_keep_alive.dart'; ...@@ -9,6 +9,7 @@ import 'automatic_keep_alive.dart';
import 'basic.dart'; import 'basic.dart';
import 'framework.dart'; import 'framework.dart';
import 'selection_container.dart'; import 'selection_container.dart';
import 'two_dimensional_viewport.dart';
export 'package:flutter/rendering.dart' show export 'package:flutter/rendering.dart' show
SliverGridDelegate, SliverGridDelegate,
...@@ -630,7 +631,7 @@ class SliverChildListDelegate extends SliverChildDelegate { ...@@ -630,7 +631,7 @@ class SliverChildListDelegate extends SliverChildDelegate {
/// [children] such as `someWidget.children.add(...)` or /// [children] such as `someWidget.children.add(...)` or
/// passing a reference of the original list value to the [children] parameter /// passing a reference of the original list value to the [children] parameter
/// will result in incorrect behaviors. Whenever the /// will result in incorrect behaviors. Whenever the
/// children list is modified, a new list object should be provided. /// children list is modified, a new list object must be provided.
/// ///
/// The following code corrects the problem mentioned above. /// The following code corrects the problem mentioned above.
/// ///
...@@ -861,3 +862,272 @@ Widget _createErrorWidget(Object exception, StackTrace stackTrace) { ...@@ -861,3 +862,272 @@ Widget _createErrorWidget(Object exception, StackTrace stackTrace) {
FlutterError.reportError(details); FlutterError.reportError(details);
return ErrorWidget.builder(details); return ErrorWidget.builder(details);
} }
// TODO(Piinks): Come back and add keep alive support, https://github.com/flutter/flutter/issues/126297
/// A delegate that supplies children for scrolling in two dimensions.
///
/// A [TwoDimensionalScrollView] lazily constructs its box children to avoid
/// creating more children than are visible through the
/// [TwoDimensionalViewport]. Rather than receiving children as an
/// explicit [List], it receives its children using a
/// [TwoDimensionalChildDelegate].
///
/// As a ChangeNotifier, this delegate allows subclasses to notify its listeners
/// (typically as a subclass of [RenderTwoDimensionalViewport]) to rebuild when
/// aspects of the delegate change. When values returned by getters or builders
/// on this delegate change, [notifyListeners] should be called. This signals to
/// the [RenderTwoDimensionalViewport] that the getters and builders need to be
/// re-queried to update the layout of children in the viewport.
///
/// See also:
///
/// * [TwoDimensionalChildBuilderDelegate], an concrete subclass of this that
/// lazily builds children on demand.
/// * [TwoDimensionalChildListDelegate], an concrete subclass of this that
/// uses a two dimensional array to layout children.
abstract class TwoDimensionalChildDelegate extends ChangeNotifier {
/// Returns the child with the given [ChildVicinity], which is described in
/// terms of x and y indices.
///
/// Subclasses must implement this function and will typically wrap their
/// children in [RepaintBoundary] widgets.
///
/// The values returned by this method are cached. To indicate that the
/// widgets have changed, a new delegate must be provided, and the new
/// delegate's [shouldRebuild] method must return true. Alternatively,
/// calling [notifyListeners] will allow the same delegate to be used.
Widget? build(BuildContext context, ChildVicinity vicinity);
/// Called whenever a new instance of the child delegate class is
/// provided.
///
/// If the new instance represents different information than the old
/// instance, then the method should return true, otherwise it should return
/// false.
///
/// If the method returns false, then the [build] call might be optimized
/// away.
bool shouldRebuild(covariant TwoDimensionalChildDelegate oldDelegate);
}
/// A delegate that supplies children for a [TwoDimensionalScrollView] using a
/// builder callback.
///
/// The widgets returned from the builder callback are automatically wrapped in
/// [RepaintBoundary] widgets if [addRepaintBoundaries] is true
/// (also the default).
///
/// See also:
///
/// * [TwoDimensionalChildListDelegate], which is a similar delegate that has an
/// explicit two dimensional array of children.
/// * [SliverChildBuilderDelegate], which is a delegate that uses a builder
/// callback to construct the children in one dimension instead of two.
/// * [SliverChildListDelegate], which is a delegate that has an explicit list
/// of children in one dimension instead of two.
class TwoDimensionalChildBuilderDelegate extends TwoDimensionalChildDelegate {
/// Creates a delegate that supplies children for a [TwoDimensionalScrollView]
/// using the given builder callback.
TwoDimensionalChildBuilderDelegate({
this.addRepaintBoundaries = true,
required this.builder,
int? maxXIndex,
int? maxYIndex,
}) : _maxYIndex = maxYIndex,
_maxXIndex = maxXIndex;
/// Called to build children on demand.
///
/// Implementors of [RenderTwoDimensionalViewport.layoutChildSequence]
/// call this builder to create the children of the viewport. For
/// [ChildVicinity] indices greater than [maxXIndex] or [maxYIndex], null will
/// be returned by the default [build] implementation. This default behavior
/// can be changed by overriding the build method.
///
/// Must return null if asked to build a widget with a [ChildVicinity] that
/// does not exist.
///
/// The delegate wraps the children returned by this builder in
/// [RepaintBoundary] widgets if [addRepaintBoundaries] is true.
final TwoDimensionalIndexedWidgetBuilder builder;
/// The maximum [ChildVicinity.xIndex] for children in the x axis.
///
/// {@template flutter.widgets.twoDimensionalChildBuilderDelegate.maxIndex}
/// For each [ChildVicinity], the child's relative location is described in
/// terms of x and y indices to facilitate a consistent visitor pattern for
/// all children in the viewport.
///
/// This is fairly straightforward in the context of a table implementation,
/// where there is usually the same number of columns in every row and vice
/// versa, each aligned one after the other.
///
/// When plotting children more abstractly in two dimensional space, there may
/// be more x indices for a given y index than another y index. An example of
/// this would be a scatter plot where there are more children at the top of
/// the graph than at the bottom.
///
/// If null, subclasses of [RenderTwoDimensionalViewport] can continue call on
/// the [builder] until null has been returned for each known index of x and
/// y. In some cases, null may not be a terminating result, such as a table
/// with a merged cell spanning multiple indices. Refer to the
/// [TwoDimensionalViewport] subclass to learn how this value is applied in
/// the specific use case.
///
/// If the value changes, the delegate will call [notifyListeners]. This
/// informs the [RenderTwoDimensionalViewport] that any cached information
/// from the delegate is invalid.
/// {@endtemplate}
///
/// This value represents the greatest x index of all [ChildVicinity]s for the
/// two dimensional scroll view.
///
/// See also:
///
/// * [RenderTwoDimensionalViewport.buildOrObtainChildFor], the method that
/// leads to calling on the delegate to build a child of the given
/// [ChildVicinity].
int? get maxXIndex => _maxXIndex;
int? _maxXIndex;
set maxXIndex(int? value) {
if (value == maxXIndex) {
return;
}
_maxXIndex = value;
notifyListeners();
}
/// The maximum [ChildVicinity.yIndex] for children in the y axis.
///
/// {@macro flutter.widgets.twoDimensionalChildBuilderDelegate.maxIndex}
///
/// This value represents the greatest y index of all [ChildVicinity]s for the
/// two dimensional scroll view.
///
/// See also:
///
/// * [RenderTwoDimensionalViewport.buildOrObtainChildFor], the method that
/// leads to calling on the delegate to build a child of the given
/// [ChildVicinity].
int? get maxYIndex => _maxYIndex;
int? _maxYIndex;
set maxYIndex(int? value) {
if (maxYIndex == value) {
return;
}
_maxYIndex = value;
notifyListeners();
}
/// {@macro flutter.widgets.SliverChildBuilderDelegate.addRepaintBoundaries}
final bool addRepaintBoundaries;
@override
Widget? build(BuildContext context, ChildVicinity vicinity) {
// If we have exceeded explicit upper bounds, return null.
if (vicinity.xIndex < 0 || (maxXIndex != null && vicinity.xIndex > maxXIndex!)) {
return null;
}
if (vicinity.yIndex < 0 || (maxYIndex != null && vicinity.yIndex > maxYIndex!)) {
return null;
}
Widget? child;
try {
child = builder(context, vicinity);
} catch (exception, stackTrace) {
child = _createErrorWidget(exception, stackTrace);
}
if (child == null) {
return null;
}
if (addRepaintBoundaries) {
child = RepaintBoundary(child: child);
}
return child;
}
@override
bool shouldRebuild(covariant TwoDimensionalChildDelegate oldDelegate) => true;
}
/// A delegate that supplies children for a [TwoDimensionalViewport] using an
/// explicit two dimensional array.
///
/// In general, building all the widgets in advance is not efficient. It is
/// better to create a delegate that builds them on demand using
/// [TwoDimensionalChildBuilderDelegate] or by subclassing
/// [TwoDimensionalChildDelegate] directly.
///
/// This class is provided for the cases where either the list of children is
/// known well in advance (ideally the children are themselves compile-time
/// constants, for example), and therefore will not be built each time the
/// delegate itself is created, or the array is small, such that it's likely
/// always visible (and thus there is nothing to be gained by building it on
/// demand).
///
/// The widgets in the given [children] list are automatically wrapped in
/// [RepaintBoundary] widgets if [addRepaintBoundaries] is true
/// (also the default).
///
/// The [children] are accessed for each [ChildVicinity.yIndex] and
/// [ChildVicinity.xIndex] of the [TwoDimensionalViewport] as
/// `children[vicinity.yIndex][vicinity.xIndex]`.
///
/// See also:
///
/// * [TwoDimensionalChildBuilderDelegate], which is a delegate that uses a
/// builder callback to construct the children.
/// * [SliverChildBuilderDelegate], which is a delegate that uses a builder
/// callback to construct the children in one dimension instead of two.
/// * [SliverChildListDelegate], which is a delegate that has an explicit list
/// of children in one dimension instead of two.
class TwoDimensionalChildListDelegate extends TwoDimensionalChildDelegate {
/// Creates a delegate that supplies children for a [TwoDimensionalScrollView].
///
/// The [children] and [addRepaintBoundaries] must not be
/// null.
TwoDimensionalChildListDelegate({
this.addRepaintBoundaries = true,
required this.children,
});
/// The widgets to display.
///
/// Also, a [Widget] in Flutter is immutable, so directly modifying the
/// [children] such as `someWidget.children.add(...)` or
/// passing a reference of the original list value to the [children] parameter
/// will result in incorrect behaviors. Whenever the
/// children list is modified, a new list object must be provided.
///
/// The [children] are accessed for each [ChildVicinity.yIndex] and
/// [ChildVicinity.xIndex] of the [TwoDimensionalViewport] as
/// `children[vicinity.yIndex][vicinity.xIndex]`.
final List<List<Widget>> children;
/// {@macro flutter.widgets.SliverChildBuilderDelegate.addRepaintBoundaries}
final bool addRepaintBoundaries;
@override
Widget? build(BuildContext context, ChildVicinity vicinity) {
// If we have exceeded explicit upper bounds, return null.
if (vicinity.yIndex < 0 || vicinity.yIndex >= children.length) {
return null;
}
if (vicinity.xIndex < 0 || vicinity.xIndex >= children[vicinity.yIndex].length) {
return null;
}
Widget child = children[vicinity.yIndex][vicinity.xIndex];
if (addRepaintBoundaries) {
child = RepaintBoundary(child: child);
}
return child;
}
@override
bool shouldRebuild(covariant TwoDimensionalChildListDelegate oldDelegate) {
return children != oldDelegate.children;
}
}
...@@ -21,6 +21,7 @@ import 'scroll_delegate.dart'; ...@@ -21,6 +21,7 @@ import 'scroll_delegate.dart';
import 'scroll_notification.dart'; import 'scroll_notification.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'scrollable_helpers.dart';
import 'sliver.dart'; import 'sliver.dart';
import 'sliver_prototype_extent_list.dart'; import 'sliver_prototype_extent_list.dart';
import 'viewport.dart'; import 'viewport.dart';
...@@ -39,7 +40,8 @@ enum ScrollViewKeyboardDismissBehavior { ...@@ -39,7 +40,8 @@ enum ScrollViewKeyboardDismissBehavior {
onDrag, onDrag,
} }
/// A widget that scrolls. /// A widget that combines a [Scrollable] and a [Viewport] to create an
/// interactive scrolling pane of content in one dimension.
/// ///
/// Scrollable widgets consist of three pieces: /// Scrollable widgets consist of three pieces:
/// ///
...@@ -71,6 +73,8 @@ enum ScrollViewKeyboardDismissBehavior { ...@@ -71,6 +73,8 @@ enum ScrollViewKeyboardDismissBehavior {
/// effects using slivers. /// effects using slivers.
/// * [ScrollNotification] and [NotificationListener], which can be used to watch /// * [ScrollNotification] and [NotificationListener], which can be used to watch
/// the scroll position without using a [ScrollController]. /// the scroll position without using a [ScrollController].
/// * [TwoDimensionalScrollView], which is a similar widget [ScrollView] that
/// scrolls in two dimensions.
abstract class ScrollView extends StatelessWidget { abstract class ScrollView extends StatelessWidget {
/// Creates a widget that scrolls. /// Creates a widget that scrolls.
/// ///
...@@ -1769,7 +1773,7 @@ class ListView extends BoxScrollView { ...@@ -1769,7 +1773,7 @@ class ListView extends BoxScrollView {
/// {@end-tool} /// {@end-tool}
/// ///
/// By default, [GridView] will automatically pad the limits of the /// By default, [GridView] will automatically pad the limits of the
/// grids's scrollable to avoid partial obstructions indicated by /// grid's scrollable to avoid partial obstructions indicated by
/// [MediaQuery]'s padding. To avoid this behavior, override with a /// [MediaQuery]'s padding. To avoid this behavior, override with a
/// zero [padding] property. /// zero [padding] property.
/// ///
......
...@@ -40,7 +40,12 @@ export 'package:flutter/physics.dart' show Tolerance; ...@@ -40,7 +40,12 @@ export 'package:flutter/physics.dart' show Tolerance;
/// scrollable content is displayed. /// scrollable content is displayed.
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position); typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);
/// A widget that scrolls. /// Signature used by [TwoDimensionalScrollable] to build the viewport through
/// which the scrollable content is displayed.
typedef TwoDimensionalViewportBuilder = Widget Function(BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition);
/// A widget that manages scrolling in one dimension and informs the [Viewport]
/// through which the content is viewed.
/// ///
/// [Scrollable] implements the interaction model for a scrollable widget, /// [Scrollable] implements the interaction model for a scrollable widget,
/// including gesture recognition, but does not have an opinion about how the /// including gesture recognition, but does not have an opinion about how the
...@@ -177,6 +182,7 @@ class Scrollable extends StatefulWidget { ...@@ -177,6 +182,7 @@ class Scrollable extends StatefulWidget {
/// slivers and sizes itself based on the size of the slivers. /// slivers and sizes itself based on the size of the slivers.
final ViewportBuilder viewportBuilder; final ViewportBuilder viewportBuilder;
/// {@template flutter.widgets.Scrollable.incrementCalculator}
/// An optional function that will be called to calculate the distance to /// An optional function that will be called to calculate the distance to
/// scroll when the scrollable is asked to scroll via the keyboard using a /// scroll when the scrollable is asked to scroll via the keyboard using a
/// [ScrollAction]. /// [ScrollAction].
...@@ -188,14 +194,17 @@ class Scrollable extends StatefulWidget { ...@@ -188,14 +194,17 @@ class Scrollable extends StatefulWidget {
/// If [incrementCalculator] is null, the default for /// If [incrementCalculator] is null, the default for
/// [ScrollIncrementType.page] is 80% of the size of the scroll window, and /// [ScrollIncrementType.page] is 80% of the size of the scroll window, and
/// for [ScrollIncrementType.line], 50 logical pixels. /// for [ScrollIncrementType.line], 50 logical pixels.
/// {@endtemplate}
final ScrollIncrementCalculator? incrementCalculator; final ScrollIncrementCalculator? incrementCalculator;
/// {@template flutter.widgets.scrollable.excludeFromSemantics}
/// Whether the scroll actions introduced by this [Scrollable] are exposed /// Whether the scroll actions introduced by this [Scrollable] are exposed
/// in the semantics tree. /// in the semantics tree.
/// ///
/// Text fields with an overflow are usually scrollable to make sure that the /// Text fields with an overflow are usually scrollable to make sure that the
/// user can get to the beginning/end of the entered text. However, these /// user can get to the beginning/end of the entered text. However, these
/// scrolling actions are generally not exposed to the semantics layer. /// scrolling actions are generally not exposed to the semantics layer.
/// {@endtemplate}
/// ///
/// See also: /// See also:
/// ///
...@@ -1611,3 +1620,690 @@ class _RestorableScrollOffset extends RestorableValue<double?> { ...@@ -1611,3 +1620,690 @@ class _RestorableScrollOffset extends RestorableValue<double?> {
@override @override
bool get enabled => value != null; bool get enabled => value != null;
} }
// 2D SCROLLING
/// Specifies how to configure the [DragGestureRecognizer]s of a
/// [TwoDimensionalScrollable].
// TODO(Piinks): Add sample code, https://github.com/flutter/flutter/issues/126298
enum DiagonalDragBehavior {
/// This behavior will not allow for any diagonal scrolling.
///
/// Drag gestures in one direction or the other will lock the input axis until
/// the gesture is released.
none,
/// This behavior will only allow diagonal scrolling on a weighted
/// scale per gesture event.
///
/// This means that after initially evaluating the drag gesture, the weighted
/// evaluation (based on [kTouchSlop]) stands until the gesture is released.
weightedEvent,
/// This behavior will only allow diagonal scrolling on a weighted
/// scale that is evaluated throughout a gesture event.
///
/// This means that during each update to the drag gesture, the scrolling
/// axis will be allowed to scroll diagonally if it exceeds the
/// [kTouchSlop].
weightedContinuous,
/// This behavior allows free movement in any and all directions when
/// dragging.
free,
}
/// A widget that manages scrolling in both the vertical and horizontal
/// dimensions and informs the [TwoDimensionalViewport] through which the
/// content is viewed.
///
/// [TwoDimensionalScrollable] implements the interaction model for a scrollable
/// widget in both the vertical and horizontal axes, including gesture
/// recognition, but does not have an opinion about how the
/// [TwoDimensionalViewport], which actually displays the children, is
/// constructed.
///
/// It's rare to construct a [TwoDimensionalScrollable] directly. Instead,
/// consider subclassing [TwoDimensionalScrollView], which combines scrolling,
/// viewporting, and a layout model in both dimensions.
///
/// See also:
///
/// * [TwoDimensionalScrollView], an abstract base class for displaying a
/// scrolling array of children in both directions.
/// * [TwoDimensionalViewport], which can be used to customize the child layout
/// model.
class TwoDimensionalScrollable extends StatefulWidget {
/// Creates a widget that scrolls in two dimensions.
///
/// The [horizontalDetails], [verticalDetails], and [viewportBuilder] must not
/// be null.
const TwoDimensionalScrollable({
super.key,
required this.horizontalDetails,
required this.verticalDetails,
required this.viewportBuilder,
this.incrementCalculator,
this.restorationId,
this.excludeFromSemantics = false,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
this.dragStartBehavior = DragStartBehavior.start,
});
/// How scrolling gestures should lock to one axis, or allow free movement
/// in both axes.
final DiagonalDragBehavior diagonalDragBehavior;
/// The configuration of the horizontal [Scrollable].
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the horizontal axis.
final ScrollableDetails horizontalDetails;
/// The configuration of the vertical [Scrollable].
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the vertical axis.
final ScrollableDetails verticalDetails;
/// Builds the viewport through which the scrollable content is displayed.
///
/// A [TwoDimensionalViewport] uses two given [ViewportOffset]s to determine
/// which part of its content is actually visible through the viewport.
///
/// See also:
///
/// * [TwoDimensionalViewport], which is a viewport that displays a span of
/// widgets in both dimensions.
final TwoDimensionalViewportBuilder viewportBuilder;
/// {@macro flutter.widgets.Scrollable.incrementCalculator}
///
/// This value applies in both axes.
final ScrollIncrementCalculator? incrementCalculator;
/// {@macro flutter.widgets.scrollable.restorationId}
///
/// Internally, the [TwoDimensionalScrollable] will introduce a
/// [RestorationScope] that will be assigned this value. The two [Scrollable]s
/// within will then be given unique IDs within this scope.
final String? restorationId;
/// {@macro flutter.widgets.scrollable.excludeFromSemantics}
///
/// This value applies to both axes.
final bool excludeFromSemantics;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
///
/// This value applies in both axes.
final DragStartBehavior dragStartBehavior;
@override
State<TwoDimensionalScrollable> createState() => TwoDimensionalScrollableState();
/// The state from the closest instance of this class that encloses the given
/// context, or null if none is found.
///
/// Typical usage is as follows:
///
/// ```dart
/// TwoDimensionalScrollableState? scrollable = TwoDimensionalScrollable.maybeOf(context);
/// ```
///
/// Calling this method will create a dependency on the closest
/// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s
/// can be accessed through [TwoDimensionalScrollableState.verticalScrollable]
/// and [TwoDimensionalScrollableState.horizontalScrollable].
///
/// Alternatively, [Scrollable.maybeOf] can be used by providing the desired
/// [Axis] to the `axis` parameter.
///
/// See also:
///
/// * [TwoDimensionalScrollable.of], which is similar to this method, but
/// asserts if no [Scrollable] ancestor is found.
static TwoDimensionalScrollableState? maybeOf(BuildContext context) {
final _TwoDimensionalScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_TwoDimensionalScrollableScope>();
return widget?.twoDimensionalScrollable;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// Typical usage is as follows:
///
/// ```dart
/// TwoDimensionalScrollableState scrollable = TwoDimensionalScrollable.of(context);
/// ```
///
/// Calling this method will create a dependency on the closest
/// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s
/// can be accessed through [TwoDimensionalScrollableState.verticalScrollable]
/// and [TwoDimensionalScrollableState.horizontalScrollable].
///
/// If no [TwoDimensionalScrollable] ancestor is found, then this method will
/// assert in debug mode, and throw an exception in release mode.
///
/// Alternatively, [Scrollable.of] can be used by providing the desired [Axis]
/// to the `axis` parameter.
///
/// See also:
///
/// * [TwoDimensionalScrollable.maybeOf], which is similar to this method,
/// but returns null if no [TwoDimensionalScrollable] ancestor is found.
static TwoDimensionalScrollableState of(BuildContext context) {
final TwoDimensionalScrollableState? scrollableState = maybeOf(context);
assert(() {
if (scrollableState == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'TwoDimensionalScrollable.of() was called with a context that does '
'not contain a TwoDimensionalScrollable widget.\n'
),
ErrorDescription(
'No TwoDimensionalScrollable widget ancestor could be found starting '
'from the context that was passed to TwoDimensionalScrollable.of(). '
'This can happen because you are using a widget that looks for a '
'TwoDimensionalScrollable ancestor, but no such ancestor exists.\n'
'The context used was:\n'
' $context',
),
]);
}
return true;
}());
return scrollableState!;
}
}
/// State object for a [TwoDimensionalScrollable] widget.
///
/// To manipulate one of the internal [Scrollable] widget's scroll position, use
/// the object obtained from the [verticalScrollable] or [horizontalScrollable]
/// property.
///
/// To be informed of when a [TwoDimensionalScrollable] widget is scrolling,
/// use a [NotificationListener] to listen for [ScrollNotification]s.
/// Both axes will have the same viewport depth since there is only one
/// viewport, and so should be differentiated by the [Axis] of the
/// [ScrollMetrics] provided by the notification.
class TwoDimensionalScrollableState extends State<TwoDimensionalScrollable> {
ScrollController? _verticalFallbackController;
ScrollController? _horizontalFallbackController;
final GlobalKey<ScrollableState> _verticalOuterScrollableKey = GlobalKey<ScrollableState>();
final GlobalKey<ScrollableState> _horizontalInnerScrollableKey = GlobalKey<ScrollableState>();
/// The [ScrollableState] of the vertical axis.
///
/// Accessible by calling [TwoDimensionalScrollable.of].
///
/// Alternatively, [Scrollable.of] can be used by providing [Axis.vertical]
/// to the `axis` parameter.
ScrollableState get verticalScrollable {
assert(_verticalOuterScrollableKey.currentState != null);
return _verticalOuterScrollableKey.currentState!;
}
/// The [ScrollableState] of the horizontal axis.
///
/// Accessible by calling [TwoDimensionalScrollable.of].
///
/// Alternatively, [Scrollable.of] can be used by providing [Axis.horizontal]
/// to the `axis` parameter.
ScrollableState get horizontalScrollable {
assert(_horizontalInnerScrollableKey.currentState != null);
return _horizontalInnerScrollableKey.currentState!;
}
@override
void initState() {
if (widget.verticalDetails.controller == null) {
_verticalFallbackController = ScrollController();
}
if (widget.horizontalDetails.controller == null) {
_horizontalFallbackController = ScrollController();
}
super.initState();
}
@override
void didUpdateWidget(TwoDimensionalScrollable oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle changes in the provided/fallback scroll controllers
// Vertical
if (oldWidget.verticalDetails.controller != widget.verticalDetails.controller) {
if (oldWidget.verticalDetails.controller == null) {
// The old controller was null, meaning the fallback cannot be null.
// Dispose of the fallback.
assert(_verticalFallbackController != null);
assert(widget.verticalDetails.controller != null);
_verticalFallbackController!.dispose();
_verticalFallbackController = null;
} else if (widget.verticalDetails.controller == null) {
// If the new controller is null, we need to set up the fallback
// ScrollController.
assert(_verticalFallbackController == null);
_verticalFallbackController = ScrollController();
}
}
// Horizontal
if (oldWidget.horizontalDetails.controller != widget.horizontalDetails.controller) {
if (oldWidget.horizontalDetails.controller == null) {
// The old controller was null, meaning the fallback cannot be null.
// Dispose of the fallback.
assert(_horizontalFallbackController != null);
assert(widget.horizontalDetails.controller != null);
_horizontalFallbackController!.dispose();
_horizontalFallbackController = null;
} else if (widget.horizontalDetails.controller == null) {
// If the new controller is null, we need to set up the fallback
// ScrollController.
assert(_horizontalFallbackController == null);
_horizontalFallbackController = ScrollController();
}
}
}
@override
Widget build(BuildContext context) {
assert(
axisDirectionToAxis(widget.verticalDetails.direction) == Axis.vertical,
'TwoDimensionalScrollable.verticalDetails are not Axis.vertical.'
);
assert(
axisDirectionToAxis(widget.horizontalDetails.direction) == Axis.horizontal,
'TwoDimensionalScrollable.horizontalDetails are not Axis.horizontal.'
);
final Widget result = RestorationScope(
restorationId: widget.restorationId,
child: _VerticalOuterDimension(
key: _verticalOuterScrollableKey,
axisDirection: widget.verticalDetails.direction,
controller: widget.verticalDetails.controller
?? _verticalFallbackController!,
physics: widget.verticalDetails.physics,
clipBehavior: widget.verticalDetails.clipBehavior
?? widget.verticalDetails.decorationClipBehavior
?? Clip.hardEdge,
incrementCalculator: widget.incrementCalculator,
excludeFromSemantics: widget.excludeFromSemantics,
restorationId: 'OuterVerticalTwoDimensionalScrollable',
dragStartBehavior: widget.dragStartBehavior,
diagonalDragBehavior: widget.diagonalDragBehavior,
viewportBuilder: (BuildContext context, ViewportOffset verticalOffset) {
return _HorizontalInnerDimension(
key: _horizontalInnerScrollableKey,
axisDirection: widget.horizontalDetails.direction,
controller: widget.horizontalDetails.controller
?? _horizontalFallbackController!,
physics: widget.horizontalDetails.physics,
clipBehavior: widget.horizontalDetails.clipBehavior
?? widget.horizontalDetails.decorationClipBehavior
?? Clip.hardEdge,
incrementCalculator: widget.incrementCalculator,
excludeFromSemantics: widget.excludeFromSemantics,
restorationId: 'InnerHorizontalTwoDimensionalScrollable',
dragStartBehavior: widget.dragStartBehavior,
diagonalDragBehavior: widget.diagonalDragBehavior,
viewportBuilder: (BuildContext context, ViewportOffset horizontalOffset) {
return widget.viewportBuilder(context, verticalOffset, horizontalOffset);
},
);
}
)
);
// TODO(Piinks): Build scrollbars for 2 dimensions instead of 1,
// https://github.com/flutter/flutter/issues/122348
return _TwoDimensionalScrollableScope(
twoDimensionalScrollable: this,
child: result,
);
}
@override
void dispose() {
_verticalFallbackController?.dispose();
_horizontalFallbackController?.dispose();
super.dispose();
}
}
// Enable TwoDimensionalScrollable.of() to work as if
// TwoDimensionalScrollableState was an inherited widget.
// TwoDimensionalScrollableState.build() always rebuilds its
// _TwoDimensionalScrollableScope.
class _TwoDimensionalScrollableScope extends InheritedWidget {
const _TwoDimensionalScrollableScope({
required this.twoDimensionalScrollable,
required super.child,
});
final TwoDimensionalScrollableState twoDimensionalScrollable;
@override
bool updateShouldNotify(_TwoDimensionalScrollableScope old) => false;
}
// Vertical outer scrollable of 2D scrolling
class _VerticalOuterDimension extends Scrollable {
const _VerticalOuterDimension({
super.key,
required super.viewportBuilder,
required super.axisDirection,
super.controller,
super.physics,
super.clipBehavior,
super.incrementCalculator,
super.excludeFromSemantics,
super.dragStartBehavior,
super.restorationId,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
}) : assert(axisDirection == AxisDirection.up || axisDirection == AxisDirection.down);
final DiagonalDragBehavior diagonalDragBehavior;
@override
_VerticalOuterDimensionState createState() => _VerticalOuterDimensionState();
}
class _VerticalOuterDimensionState extends ScrollableState {
DiagonalDragBehavior get diagonalDragBehavior => (widget as _VerticalOuterDimension).diagonalDragBehavior;
@override
void setCanDrag(bool value) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// If we aren't scrolling diagonally, the default drag gesture
// recognizer is used.
super.setCanDrag(value);
return;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
if (value) {
// If a type of diagonal scrolling is enabled, a panning gesture
// recognizer will be created for the _InnerDimension. So in this
// case, the _OuterDimension does not require a gesture recognizer.
_gestureRecognizers = const <Type, GestureRecognizerFactory>{};
// Cancel the active hold/drag (if any) because the gesture recognizers
// will soon be disposed by our RawGestureDetector, and we won't be
// receiving pointer up events to cancel the hold/drag.
_handleDragCancel();
_lastCanDrag = value;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
}
return;
}
}
@override
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
clipBehavior: widget.clipBehavior,
);
// Skip building a scrollbar here, the dual scrollbar is added in
// TwoDimensionalScrollableState.
return _configuration.buildOverscrollIndicator(context, child, details);
}
}
// Horizontal inner scrollable of 2D scrolling
class _HorizontalInnerDimension extends Scrollable {
const _HorizontalInnerDimension({
super.key,
required super.viewportBuilder,
required super.axisDirection,
super.controller,
super.physics,
super.clipBehavior,
super.incrementCalculator,
super.excludeFromSemantics,
super.dragStartBehavior,
super.restorationId,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
}) : assert(axisDirection == AxisDirection.left || axisDirection == AxisDirection.right);
final DiagonalDragBehavior diagonalDragBehavior;
@override
_HorizontalInnerDimensionState createState() => _HorizontalInnerDimensionState();
}
class _HorizontalInnerDimensionState extends ScrollableState {
late ScrollableState verticalScrollable;
Axis? lockedAxis;
Offset? lastDragOffset;
DiagonalDragBehavior get diagonalDragBehavior => (widget as _HorizontalInnerDimension).diagonalDragBehavior;
@override
void didChangeDependencies() {
verticalScrollable = Scrollable.of(context);
assert(axisDirectionToAxis(verticalScrollable.axisDirection) == Axis.vertical);
super.didChangeDependencies();
}
void _evaluateLockedAxis(Offset offset) {
assert(lastDragOffset != null);
final Offset offsetDelta = lastDragOffset! - offset;
final double axisDifferential = offsetDelta.dx.abs() - offsetDelta.dy.abs();
if (axisDifferential.abs() >= kTouchSlop) {
// We have single axis winner.
lockedAxis = axisDifferential > 0.0 ? Axis.horizontal : Axis.vertical;
} else {
lockedAxis = null;
}
}
@override
void _handleDragDown(DragDownDetails details) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
// Initiate hold. If one or the other wins the gesture, cancel the
// opposite axis.
verticalScrollable._handleDragDown(details);
}
super._handleDragDown(details);
}
@override
void _handleDragStart(DragStartDetails details) {
lastDragOffset = details.globalPosition;
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
// See if one axis wins the drag.
_evaluateLockedAxis(details.globalPosition);
switch (lockedAxis) {
case null:
// Prepare to scroll diagonally
verticalScrollable._handleDragStart(details);
case Axis.horizontal:
// Prepare to scroll horizontally.
super._handleDragStart(details);
return;
case Axis.vertical:
// Prepare to scroll vertically.
verticalScrollable._handleDragStart(details);
return;
}
case DiagonalDragBehavior.free:
verticalScrollable._handleDragStart(details);
}
super._handleDragStart(details);
}
@override
void _handleDragUpdate(DragUpdateDetails details) {
final DragUpdateDetails verticalDragDetails = DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: Offset(0.0, details.delta.dy),
primaryDelta: details.delta.dy,
globalPosition: details.globalPosition,
localPosition: details.localPosition,
);
final DragUpdateDetails horizontalDragDetails = DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: Offset(details.delta.dx, 0.0),
primaryDelta: details.delta.dx,
globalPosition: details.globalPosition,
localPosition: details.localPosition,
);
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// Default gesture handling from super class.
super._handleDragUpdate(horizontalDragDetails);
return;
case DiagonalDragBehavior.free:
// Scroll both axes
verticalScrollable._handleDragUpdate(verticalDragDetails);
super._handleDragUpdate(horizontalDragDetails);
return;
case DiagonalDragBehavior.weightedContinuous:
// Re-evaluate locked axis for every update.
_evaluateLockedAxis(details.globalPosition);
lastDragOffset = details.globalPosition;
case DiagonalDragBehavior.weightedEvent:
// Lock axis only once per gesture.
if (lockedAxis == null && lastDragOffset != null) {
// A winner has not been declared yet.
// See if one axis has won the drag.
_evaluateLockedAxis(details.globalPosition);
}
}
switch (lockedAxis) {
case null:
// Scroll diagonally
verticalScrollable._handleDragUpdate(verticalDragDetails);
super._handleDragUpdate(horizontalDragDetails);
case Axis.horizontal:
// Scroll horizontally
super._handleDragUpdate(horizontalDragDetails);
return;
case Axis.vertical:
// Scroll vertically
verticalScrollable._handleDragUpdate(verticalDragDetails);
return;
}
}
@override
void _handleDragEnd(DragEndDetails details) {
lastDragOffset = null;
lockedAxis = null;
final double dx = details.velocity.pixelsPerSecond.dx;
final double dy = details.velocity.pixelsPerSecond.dy;
final DragEndDetails verticalDragDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(0.0, dy)),
primaryVelocity: details.velocity.pixelsPerSecond.dy,
);
final DragEndDetails horizontalDragDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(dx, 0.0)),
primaryVelocity: details.velocity.pixelsPerSecond.dx,
);
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
verticalScrollable._handleDragEnd(verticalDragDetails);
}
super._handleDragEnd(horizontalDragDetails);
}
@override
void _handleDragCancel() {
lastDragOffset = null;
lockedAxis = null;
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
verticalScrollable._handleDragCancel();
}
super._handleDragCancel();
}
@override
void setCanDrag(bool value) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// If we aren't scrolling diagonally, the default drag gesture recognizer
// is used.
super.setCanDrag(value);
return;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
if (value) {
// Replaces the typical vertical/horizontal drag gesture recognizers
// with a pan gesture recognizer to allow bidirectional scrolling.
// Based on the diagonalDragBehavior, valid horizontal deltas are
// applied to this scrollable, while vertical deltas are routed to
// the vertical scrollable.
_gestureRecognizers = <Type, GestureRecognizerFactory>{
PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(supportedDevices: _configuration.dragDevices),
(PanGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..gestureSettings = _mediaQueryGestureSettings;
},
),
};
// Cancel the active hold/drag (if any) because the gesture recognizers
// will soon be disposed by our RawGestureDetector, and we won't be
// receiving pointer up events to cancel the hold/drag.
_handleDragCancel();
_lastCanDrag = value;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
}
return;
}
}
@override
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
clipBehavior: widget.clipBehavior,
);
// Skip building a scrollbar here, the dual scrollbar is added in
// TwoDimensionalScrollableState.
return _configuration.buildOverscrollIndicator(context, child, details);
}
}
...@@ -22,10 +22,8 @@ import 'scrollable.dart'; ...@@ -22,10 +22,8 @@ import 'scrollable.dart';
export 'package:flutter/physics.dart' show Tolerance; export 'package:flutter/physics.dart' show Tolerance;
/// Describes the aspects of a Scrollable widget to inform inherited widgets /// Describes the aspects of a Scrollable widget to inform inherited widgets
/// like [ScrollBehavior] for decorating. /// like [ScrollBehavior] for decorating or enumerate the properties of combined
// TODO(Piinks): Fix doc with 2DScrollable change. /// Scrollables, such as [TwoDimensionalScrollable].
// or enumerate the properties of combined
// Scrollables, such as [TwoDimensionalScrollable].
/// ///
/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require /// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require
/// information about the Scrollable in order to be initialized. /// information about the Scrollable in order to be initialized.
......
// Copyright 2014 The Flutter 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 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'notification_listener.dart';
import 'primary_scroll_controller.dart';
import 'scroll_controller.dart';
import 'scroll_delegate.dart';
import 'scroll_notification.dart';
import 'scroll_physics.dart';
import 'scroll_view.dart';
import 'scrollable.dart';
import 'scrollable_helpers.dart';
import 'two_dimensional_viewport.dart';
/// A widget that combines a [TwoDimensionalScrollable] and a
/// [TwoDimensionalViewport] to create an interactive scrolling pane of content
/// in both vertical and horizontal dimensions.
///
/// A two-way scrollable widget consist of three pieces:
///
/// 1. A [TwoDimensionalScrollable] widget, which listens for various user
/// gestures and implements the interaction design for scrolling.
/// 2. A [TwoDimensionalViewport] widget, which implements the visual design
/// for scrolling by displaying only a portion
/// of the widgets inside the scroll view.
/// 3. A [TwoDimensionalChildDelegate], which provides the children visible in
/// the scroll view.
///
/// [TwoDimensionalScrollView] helps orchestrate these pieces by creating the
/// [TwoDimensionalScrollable] and deferring to its subclass to implement
/// [buildViewport], which builds a subclass of [TwoDimensionalViewport]. The
/// [TwoDimensionalChildDelegate] is provided by the [delegate] parameter.
///
/// A [TwoDimensionalScrollView] has two different [ScrollPosition]s, one for
/// each [Axis]. This means that there are also two unique [ScrollController]s
/// for these positions. To provide a ScrollController to access the
/// ScrollPosition, use the [ScrollableDetails.controller] property of the
/// associated axis that is provided to this scroll view.
abstract class TwoDimensionalScrollView extends StatelessWidget {
/// Creates a widget that scrolls in both dimensions.
///
/// The [primary] argument is associated with the [mainAxis]. The main axis
/// [ScrollableDetails.controller] must be null if [primary] is configured for
/// that axis. If [primary] is true, the nearest [PrimaryScrollController]
/// surrounding the widget is attached to the scroll position of that axis.
const TwoDimensionalScrollView({
super.key,
this.primary,
this.mainAxis = Axis.vertical,
this.verticalDetails = const ScrollableDetails.vertical(),
this.horizontalDetails = const ScrollableDetails.horizontal(),
required this.delegate,
this.cacheExtent,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.clipBehavior = Clip.hardEdge,
});
/// A delegate that provides the children for the [TwoDimensionalScrollView].
final TwoDimensionalChildDelegate delegate;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// Whether scrolling gestures should lock to one axes, allow free movement
/// in both axes, or be evaluated on a weighted scale.
///
/// Defaults to [DiagonalDragBehavior.none], locking axes to receive input one
/// at a time.
final DiagonalDragBehavior diagonalDragBehavior;
/// {@macro flutter.widgets.scroll_view.primary}
final bool? primary;
/// The main axis of the two.
///
/// Used to determine how to apply [primary] when true.
///
/// This value should also be provided to the subclass of
/// [TwoDimensionalViewport], where it is used to determine paint order of
/// children.
final Axis mainAxis;
/// The configuration of the vertical Scrollable.
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the vertical axis.
final ScrollableDetails verticalDetails;
/// The configuration of the horizontal Scrollable.
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the horizontal axis.
final ScrollableDetails horizontalDetails;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.widgets.scroll_view.keyboardDismissBehavior}
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
final Clip clipBehavior;
/// Build the two dimensional viewport.
///
/// Subclasses may override this method to change how the viewport is built,
/// likely a subclass of [TwoDimensionalViewport].
///
/// The `verticalOffset` and `horizontalOffset` arguments are the values
/// obtained from [TwoDimensionalScrollable.viewportBuilder].
Widget buildViewport(
BuildContext context,
ViewportOffset verticalOffset,
ViewportOffset horizontalOffset,
);
@override
Widget build(BuildContext context) {
assert(
axisDirectionToAxis(verticalDetails.direction) == Axis.vertical,
'TwoDimensionalScrollView.verticalDetails are not Axis.vertical.'
);
assert(
axisDirectionToAxis(horizontalDetails.direction) == Axis.horizontal,
'TwoDimensionalScrollView.horizontalDetails are not Axis.horizontal.'
);
ScrollableDetails mainAxisDetails = switch (mainAxis) {
Axis.vertical => verticalDetails,
Axis.horizontal => horizontalDetails,
};
final bool effectivePrimary = primary
?? mainAxisDetails.controller == null && PrimaryScrollController.shouldInherit(
context,
mainAxis,
);
if (effectivePrimary) {
// Using PrimaryScrollController for mainAxis.
assert(
mainAxisDetails.controller == null,
'TwoDimensionalScrollView.primary was explicitly set to true, but a '
'ScrollController was provided in the ScrollableDetails of the '
'TwoDimensionalScrollView.mainAxis.'
);
mainAxisDetails = mainAxisDetails.copyWith(
controller: PrimaryScrollController.of(context),
);
}
final TwoDimensionalScrollable scrollable = TwoDimensionalScrollable(
horizontalDetails : switch (mainAxis) {
Axis.horizontal => mainAxisDetails,
Axis.vertical => horizontalDetails,
},
verticalDetails: switch (mainAxis) {
Axis.vertical => mainAxisDetails,
Axis.horizontal => verticalDetails,
},
diagonalDragBehavior: diagonalDragBehavior,
viewportBuilder: buildViewport,
dragStartBehavior: dragStartBehavior,
);
final Widget scrollableResult = effectivePrimary
// Further descendant ScrollViews will not inherit the same PrimaryScrollController
? PrimaryScrollController.none(child: scrollable)
: scrollable;
if (keyboardDismissBehavior == ScrollViewKeyboardDismissBehavior.onDrag) {
return NotificationListener<ScrollUpdateNotification>(
child: scrollableResult,
onNotification: (ScrollUpdateNotification notification) {
final FocusScopeNode focusScope = FocusScope.of(context);
if (notification.dragDetails != null && focusScope.hasFocus) {
focusScope.unfocus();
}
return false;
},
);
}
return scrollableResult;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('mainAxis', mainAxis));
properties.add(EnumProperty<DiagonalDragBehavior>('diagonalDragBehavior', diagonalDragBehavior));
properties.add(FlagProperty('primary', value: primary, ifTrue: 'using primary controller', showName: true));
properties.add(DiagnosticsProperty<ScrollableDetails>('verticalDetails', verticalDetails, showName: false));
properties.add(DiagnosticsProperty<ScrollableDetails>('horizontalDetails', horizontalDetails, showName: false));
}
}
// Copyright 2014 The Flutter 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 'framework.dart';
import 'scroll_delegate.dart';
import 'scroll_notification.dart';
import 'scroll_position.dart';
export 'package:flutter/rendering.dart' show AxisDirection;
// Examples can assume:
// late final RenderBox child;
// late final BoxConstraints constraints;
// class RenderSimpleTwoDimensionalViewport extends RenderTwoDimensionalViewport {
// RenderSimpleTwoDimensionalViewport({
// required super.horizontalOffset,
// required super.horizontalAxisDirection,
// required super.verticalOffset,
// required super.verticalAxisDirection,
// required super.delegate,
// required super.mainAxis,
// required super.childManager,
// super.cacheExtent,
// super.clipBehavior = Clip.hardEdge,
// });
// @override
// void layoutChildSequence() { }
// }
/// Signature for a function that creates a widget for a given [ChildVicinity],
/// e.g., in a [TwoDimensionalScrollView], but may return null.
///
/// Used by [TwoDimensionalChildBuilderDelegate.builder] and other APIs that
/// use lazily-generated widgets where the child count may not be known
/// ahead of time.
///
/// Unlike most builders, this callback can return null, indicating the
/// [ChildVicinity.xIndex] or [ChildVicinity.yIndex] is out of range. Whether
/// and when this is valid depends on the semantics of the builder. For example,
/// [TwoDimensionalChildBuilderDelegate.builder] returns
/// null when one or both of the indices is out of range, where the range is
/// defined by the [TwoDimensionalChildBuilderDelegate.maxXIndex] or
/// [TwoDimensionalChildBuilderDelegate.maxYIndex]; so in that case the
/// vicinity values may determine whether returning null is valid or not.
///
/// See also:
///
/// * [WidgetBuilder], which is similar but only takes a [BuildContext].
/// * [NullableIndexedWidgetBuilder], which is similar but may return null.
/// * [IndexedWidgetBuilder], which is similar but not nullable.
typedef TwoDimensionalIndexedWidgetBuilder = Widget? Function(BuildContext, ChildVicinity vicinity);
/// A widget through which a portion of larger content can be viewed, typically
/// in combination with a [TwoDimensionalScrollable].
///
/// [TwoDimensionalViewport] is the visual workhorse of the two dimensional
/// scrolling machinery. It displays a subset of its children according to its
/// own dimensions and the given [horizontalOffset] an [verticalOffset]. As the
/// offsets vary, different children are visible through the viewport.
///
/// Subclasses must implement [createRenderObject] and [updateRenderObject].
/// Both of these methods require the render object to be a subclass of
/// [RenderTwoDimensionalViewport]. This class will create its own
/// [RenderObjectElement] which already implements the
/// [TwoDimensionalChildManager], which means subclasses should cast the
/// [BuildContext] to provide as the child manager to the
/// [RenderTwoDimensionalViewport].
///
/// {@tool snippet}
/// This is an example of a subclass implementation of [TwoDimensionalViewport],
/// `SimpleTwoDimensionalViewport`. The `RenderSimpleTwoDimensionalViewport` is
/// a subclass of [RenderTwoDimensionalViewport].
///
/// ```dart
/// class SimpleTwoDimensionalViewport extends TwoDimensionalViewport {
/// const SimpleTwoDimensionalViewport({
/// super.key,
/// required super.verticalOffset,
/// required super.verticalAxisDirection,
/// required super.horizontalOffset,
/// required super.horizontalAxisDirection,
/// required super.delegate,
/// required super.mainAxis,
/// super.cacheExtent,
/// super.clipBehavior = Clip.hardEdge,
/// });
///
/// @override
/// RenderSimpleTwoDimensionalViewport createRenderObject(BuildContext context) {
/// return RenderSimpleTwoDimensionalViewport(
/// horizontalOffset: horizontalOffset,
/// horizontalAxisDirection: horizontalAxisDirection,
/// verticalOffset: verticalOffset,
/// verticalAxisDirection: verticalAxisDirection,
/// mainAxis: mainAxis,
/// delegate: delegate,
/// childManager: context as TwoDimensionalChildManager,
/// cacheExtent: cacheExtent,
/// clipBehavior: clipBehavior,
/// );
/// }
///
/// @override
/// void updateRenderObject(BuildContext context, RenderSimpleTwoDimensionalViewport renderObject) {
/// renderObject
/// ..horizontalOffset = horizontalOffset
/// ..horizontalAxisDirection = horizontalAxisDirection
/// ..verticalOffset = verticalOffset
/// ..verticalAxisDirection = verticalAxisDirection
/// ..mainAxis = mainAxis
/// ..delegate = delegate
/// ..cacheExtent = cacheExtent
/// ..clipBehavior = clipBehavior;
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [Viewport], the equivalent of this widget that scrolls in only one
/// dimension.
abstract class TwoDimensionalViewport extends RenderObjectWidget {
/// Creates a viewport for [RenderBox] objects that extend and scroll in both
/// horizontal and vertical dimensions.
///
/// The viewport listens to the [horizontalOffset] and [verticalOffset], which
/// means this widget does not need to be rebuilt when the offsets change.
const TwoDimensionalViewport({
super.key,
required this.verticalOffset,
required this.verticalAxisDirection,
required this.horizontalOffset,
required this.horizontalAxisDirection,
required this.delegate,
required this.mainAxis,
this.cacheExtent,
this.clipBehavior = Clip.hardEdge,
}) : assert(
verticalAxisDirection == AxisDirection.down || verticalAxisDirection == AxisDirection.up,
'TwoDimensionalViewport.verticalAxisDirection is not Axis.vertical.'
),
assert(
horizontalAxisDirection == AxisDirection.left || horizontalAxisDirection == AxisDirection.right,
'TwoDimensionalViewport.horizontalAxisDirection is not Axis.horizontal.'
);
/// Which part of the content inside the viewport should be visible in the
/// vertical axis.
///
/// 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 vertically, this value changes, which changes the
/// content that is displayed.
///
/// Typically a [ScrollPosition].
final ViewportOffset verticalOffset;
/// The direction in which the [verticalOffset]'s [ViewportOffset.pixels]
/// increases.
///
/// For example, if the axis direction is [AxisDirection.down], a scroll
/// offset of zero is at the top of the viewport and increases towards the
/// bottom of the viewport.
///
/// Must be either [AxisDirection.down] or [AxisDirection.up] in correlation
/// with an [Axis.vertical].
final AxisDirection verticalAxisDirection;
/// Which part of the content inside the viewport should be visible in the
/// horizontal axis.
///
/// 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 horizontally, this value changes, which changes the
/// content that is displayed.
///
/// Typically a [ScrollPosition].
final ViewportOffset horizontalOffset;
/// The direction in which the [horizontalOffset]'s [ViewportOffset.pixels]
/// increases.
///
/// For example, if the axis direction is [AxisDirection.right], a scroll
/// offset of zero is at the left of the viewport and increases towards the
/// right of the viewport.
///
/// Must be either [AxisDirection.left] or [AxisDirection.right] in correlation
/// with an [Axis.horizontal].
final AxisDirection horizontalAxisDirection;
/// The main axis of the two.
///
/// Used to determine the paint order of the children of the viewport. When
/// the main axis is [Axis.vertical], children will be painted in row major
/// order, according to their associated [ChildVicinity]. When the main axis
/// is [Axis.horizontal], the children will be painted in column major order.
final Axis mainAxis;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// {@macro flutter.material.Material.clipBehavior}
final Clip clipBehavior;
/// A delegate that provides the children for the [TwoDimensionalViewport].
final TwoDimensionalChildDelegate delegate;
@override
RenderObjectElement createElement() => _TwoDimensionalViewportElement(this);
@override
RenderTwoDimensionalViewport createRenderObject(BuildContext context);
@override
void updateRenderObject(BuildContext context, RenderTwoDimensionalViewport renderObject);
}
class _TwoDimensionalViewportElement extends RenderObjectElement
with NotifiableElementMixin, ViewportElementMixin implements TwoDimensionalChildManager {
_TwoDimensionalViewportElement(super.widget);
@override
RenderTwoDimensionalViewport get renderObject => super.renderObject as RenderTwoDimensionalViewport;
// Contains all children, including those that are keyed.
Map<ChildVicinity, Element> _vicinityToChild = <ChildVicinity, Element>{};
Map<Key, Element> _keyToChild = <Key, Element>{};
// Used between _startLayout() & _endLayout() to compute the new values for
// _vicinityToChild and _keyToChild.
Map<ChildVicinity, Element>? _newVicinityToChild;
Map<Key, Element>? _newKeyToChild;
@override
void performRebuild() {
super.performRebuild();
// Children list is updated during layout since we only know during layout
// which children will be visible.
renderObject.markNeedsLayout(withDelegateRebuild: true);
}
@override
void forgetChild(Element child) {
assert(!_debugIsDoingLayout);
super.forgetChild(child);
_vicinityToChild.remove(child.slot);
if (child.widget.key != null) {
_keyToChild.remove(child.widget.key);
}
}
@override
void insertRenderObjectChild(RenderBox child, ChildVicinity slot) {
renderObject._insertChild(child, slot);
}
@override
void moveRenderObjectChild(RenderBox child, ChildVicinity oldSlot, ChildVicinity newSlot) {
renderObject._moveChild(child, from: oldSlot, to: newSlot);
}
@override
void removeRenderObjectChild(RenderBox child, ChildVicinity slot) {
renderObject._removeChild(child, slot);
}
@override
void visitChildren(ElementVisitor visitor) {
_vicinityToChild.values.forEach(visitor);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final List<Element> children = _vicinityToChild.values.toList()..sort(_compareChildren);
return <DiagnosticsNode>[
for (final Element child in children)
child.toDiagnosticsNode(name: child.slot.toString())
];
}
static int _compareChildren(Element a, Element b) {
final ChildVicinity aSlot = a.slot! as ChildVicinity;
final ChildVicinity bSlot = b.slot! as ChildVicinity;
return aSlot.compareTo(bSlot);
}
// ---- ChildManager implementation ----
bool get _debugIsDoingLayout => _newKeyToChild != null && _newVicinityToChild != null;
@override
void _startLayout() {
assert(!_debugIsDoingLayout);
_newVicinityToChild = <ChildVicinity, Element>{};
_newKeyToChild = <Key, Element>{};
}
@override
void _buildChild(ChildVicinity vicinity) {
assert(_debugIsDoingLayout);
owner!.buildScope(this, () {
final Widget? newWidget = (widget as TwoDimensionalViewport).delegate.build(this, vicinity);
if (newWidget == null) {
return;
}
final Element? oldElement = _retrieveOldElement(newWidget, vicinity);
final Element? newChild = updateChild(oldElement, newWidget, vicinity);
assert(newChild != null);
// Ensure we are not overwriting an existing child.
assert(_newVicinityToChild![vicinity] == null);
_newVicinityToChild![vicinity] = newChild!;
if (newWidget.key != null) {
// Ensure we are not overwriting an existing key
assert(_newKeyToChild![newWidget.key!] == null);
_newKeyToChild![newWidget.key!] = newChild;
}
});
}
Element? _retrieveOldElement(Widget newWidget, ChildVicinity vicinity) {
if (newWidget.key != null) {
final Element? result = _keyToChild.remove(newWidget.key);
if (result != null) {
_vicinityToChild.remove(result.slot);
}
return result;
}
final Element? potentialOldElement = _vicinityToChild[vicinity];
if (potentialOldElement != null && potentialOldElement.widget.key == null) {
return _vicinityToChild.remove(vicinity);
}
return null;
}
@override
void _reuseChild(ChildVicinity vicinity) {
assert(_debugIsDoingLayout);
final Element? elementToReuse = _vicinityToChild.remove(vicinity);
assert(
elementToReuse != null,
'Expected to re-use an element at $vicinity, but none was found.'
);
_newVicinityToChild![vicinity] = elementToReuse!;
if (elementToReuse.widget.key != null) {
assert(_keyToChild.containsKey(elementToReuse.widget.key));
assert(_keyToChild[elementToReuse.widget.key] == elementToReuse);
_newKeyToChild![elementToReuse.widget.key!] = _keyToChild.remove(elementToReuse.widget.key)!;
}
}
@override
void _endLayout() {
assert(_debugIsDoingLayout);
// Unmount all elements that have not been reused in the layout cycle.
for (final Element element in _vicinityToChild.values) {
if (element.widget.key == null) {
// If it has a key, we handle it below.
updateChild(element, null, null);
} else {
assert(_keyToChild.containsValue(element));
}
}
for (final Element element in _keyToChild.values) {
assert(element.widget.key != null);
updateChild(element, null, null);
}
_vicinityToChild = _newVicinityToChild!;
_keyToChild = _newKeyToChild!;
_newVicinityToChild = null;
_newKeyToChild = null;
assert(!_debugIsDoingLayout);
}
}
/// Parent data structure used by [RenderTwoDimensionalViewport].
///
/// The parent data primarily describes where a child is in the viewport. The
/// [layoutOffset] must be set by subclasses of [RenderTwoDimensionalViewport],
/// during [RenderTwoDimensionalViewport.layoutChildSequence] which represents
/// the position of the child in the viewport.
///
/// The [paintOffset] is computed by [RenderTwoDimensionalViewport] after
/// [RenderTwoDimensionalViewport.layoutChildSequence]. If subclasses of
/// RenderTwoDimensionalViewport override the paint method, the [paintOffset]
/// should be used to position the child in the viewport in order to account for
/// a reversed [AxisDirection] in one or both dimensions.
class TwoDimensionalViewportParentData extends ParentData {
/// The offset at which to paint the child in the parent's coordinate system.
///
/// This [Offset] represents the top left corner of the child of the
/// [TwoDimensionalViewport].
///
/// This value must be set by implementors during
/// [RenderTwoDimensionalViewport.layoutChildSequence]. After the method is
/// complete, the [RenderTwoDimensionalViewport] will compute the
/// [paintOffset] based on this value to account for the [AxisDirection].
Offset? layoutOffset;
/// The logical positioning of children in two dimensions.
///
/// While children may not be strictly laid out in rows and columns, the
/// relative positioning determines traversal of
/// children in row or column major format.
///
/// This is set in the [RenderTwoDimensionalViewport.buildOrObtainChildFor].
ChildVicinity vicinity = ChildVicinity.invalid;
/// Whether or not the child is actually visible within the viewport.
///
/// For example, if a child is contained within the
/// [RenderTwoDimensionalViewport.cacheExtent] and out of view.
///
/// This is used during [RenderTwoDimensionalViewport.paint] in order to skip
/// painting children that cannot be seen.
bool get isVisible {
assert(() {
if (_paintExtent == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('The paint extent of the child has not been determined yet.'),
ErrorDescription(
'The paint extent, and therefore the visibility, of a child of a '
'RenderTwoDimensionalViewport is computed after '
'RenderTwoDimensionalViewport.layoutChildSequence.'
),
]);
}
return true;
}());
return _paintExtent != Size.zero || _paintExtent!.height != 0.0 || _paintExtent!.width != 0.0;
}
/// Represents the extent in both dimensions of the child that is actually
/// visible.
///
/// For example, if a child [RenderBox] had a height of 100 pixels, and a
/// width of 100 pixels, but was scrolled to positions such that only 50
/// pixels of both width and height were visible, the paintExtent would be
/// represented as `Size(50.0, 50.0)`.
///
/// This is set in [RenderTwoDimensionalViewport.updateChildPaintData].
Size? _paintExtent;
/// The previous sibling in the parent's child list according to the traversal
/// order specified by [RenderTwoDimensionalViewport.mainAxis].
RenderBox? _previousSibling;
/// The next sibling in the parent's child list according to the traversal
/// order specified by [RenderTwoDimensionalViewport.mainAxis].
RenderBox? _nextSibling;
/// The position of the child relative to the bounds and [AxisDirection] of
/// the viewport.
///
/// This is the distance from the top left visible corner of the parent to the
/// top left visible corner of the child. When the [AxisDirection]s are
/// [AxisDirection.down] or [AxisDirection.right], this value is the same as
/// the [layoutOffset]. This value deviates when scrolling in the reverse
/// directions of [AxisDirection.up] and [AxisDirection.left] to reposition
/// the children correctly.
///
/// This is set in [RenderTwoDimensionalViewport.updateChildPaintData], after
/// [RenderTwoDimensionalViewport.layoutChildSequence].
///
/// If overriding [RenderTwoDimensionalViewport.paint], use this value to
/// position the children instead of [layoutOffset].
Offset? paintOffset;
@override
String toString() {
return 'vicinity=$vicinity; '
'layoutOffset=$layoutOffset; '
'paintOffset=$paintOffset; '
'${_paintExtent == null
? 'not visible '
: '${!isVisible ? 'not ' : ''}visible - paintExtent=$_paintExtent'}';
}
}
/// A base class for viewing render objects that scroll in two dimensions.
///
/// The viewport listens to two [ViewportOffset]s, which determines the
/// visible content.
///
/// Subclasses must implement [layoutChildSequence], calling on
/// [buildOrObtainChildFor] to manage the children of the viewport.
///
/// Subclasses should not override [performLayout], as it handles housekeeping
/// on either side of the call to [layoutChildSequence].
// TODO(Piinks): Two follow up changes:
// - Keep alive https://github.com/flutter/flutter/issues/126297
// - ensureVisible https://github.com/flutter/flutter/issues/126299
abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderAbstractViewport {
/// Initializes fields for subclasses.
///
/// The [cacheExtent], if null, defaults to
/// [RenderAbstractViewport.defaultCacheExtent].
RenderTwoDimensionalViewport({
required ViewportOffset horizontalOffset,
required AxisDirection horizontalAxisDirection,
required ViewportOffset verticalOffset,
required AxisDirection verticalAxisDirection,
required TwoDimensionalChildDelegate delegate,
required Axis mainAxis,
required TwoDimensionalChildManager childManager,
double? cacheExtent,
Clip clipBehavior = Clip.hardEdge,
}) : assert(
verticalAxisDirection == AxisDirection.down || verticalAxisDirection == AxisDirection.up,
'TwoDimensionalViewport.verticalAxisDirection is not Axis.vertical.'
),
assert(
horizontalAxisDirection == AxisDirection.left || horizontalAxisDirection == AxisDirection.right,
'TwoDimensionalViewport.horizontalAxisDirection is not Axis.horizontal.'
),
_childManager = childManager,
_horizontalOffset = horizontalOffset,
_horizontalAxisDirection = horizontalAxisDirection,
_verticalOffset = verticalOffset,
_verticalAxisDirection = verticalAxisDirection,
_delegate = delegate,
_mainAxis = mainAxis,
_cacheExtent = cacheExtent ?? RenderAbstractViewport.defaultCacheExtent,
_clipBehavior = clipBehavior;
/// Which part of the content inside the viewport should be visible in the
/// horizontal axis.
///
/// 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 horizontally, this value changes, which changes the
/// content that is displayed.
///
/// Typically a [ScrollPosition].
ViewportOffset get horizontalOffset => _horizontalOffset;
ViewportOffset _horizontalOffset;
set horizontalOffset(ViewportOffset value) {
if (_horizontalOffset == value) {
return;
}
if (attached) {
_horizontalOffset.removeListener(markNeedsLayout);
}
_horizontalOffset = value;
if (attached) {
_horizontalOffset.addListener(markNeedsLayout);
}
markNeedsLayout();
}
/// The direction in which the [horizontalOffset] increases.
///
/// For example, if the axis direction is [AxisDirection.right], a scroll
/// offset of zero is at the left of the viewport and increases towards the
/// right of the viewport.
AxisDirection get horizontalAxisDirection => _horizontalAxisDirection;
AxisDirection _horizontalAxisDirection;
set horizontalAxisDirection(AxisDirection value) {
if (_horizontalAxisDirection == value) {
return;
}
_horizontalAxisDirection = value;
markNeedsLayout();
}
/// Which part of the content inside the viewport should be visible in the
/// vertical axis.
///
/// 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 vertically, this value changes, which changes the
/// content that is displayed.
///
/// Typically a [ScrollPosition].
ViewportOffset get verticalOffset => _verticalOffset;
ViewportOffset _verticalOffset;
set verticalOffset(ViewportOffset value) {
if (_verticalOffset == value) {
return;
}
if (attached) {
_verticalOffset.removeListener(markNeedsLayout);
}
_verticalOffset = value;
if (attached) {
_verticalOffset.addListener(markNeedsLayout);
}
markNeedsLayout();
}
/// The direction in which the [verticalOffset] increases.
///
/// For example, if the axis direction is [AxisDirection.down], a scroll
/// offset of zero is at the top the viewport and increases towards the
/// bottom of the viewport.
AxisDirection get verticalAxisDirection => _verticalAxisDirection;
AxisDirection _verticalAxisDirection;
set verticalAxisDirection(AxisDirection value) {
if (_verticalAxisDirection == value) {
return;
}
_verticalAxisDirection = value;
markNeedsLayout();
}
/// Supplies children for layout in the viewport.
TwoDimensionalChildDelegate get delegate => _delegate;
TwoDimensionalChildDelegate _delegate;
set delegate(TwoDimensionalChildDelegate value) {
if (_delegate == value) {
return;
}
if (attached) {
_delegate.removeListener(_handleDelegateNotification);
}
final TwoDimensionalChildDelegate oldDelegate = _delegate;
_delegate = value;
if (attached) {
_delegate.addListener(_handleDelegateNotification);
}
if (_delegate.runtimeType != oldDelegate.runtimeType || _delegate.shouldRebuild(oldDelegate)) {
_handleDelegateNotification();
}
}
/// The major axis of the two dimensions.
///
/// This is can be used by subclasses to determine paint order,
/// visitor patterns like row and column major ordering, or hit test
/// precedence.
///
/// See also:
///
/// * [TwoDimensionalScrollView], which assigns the [PrimaryScrollController]
/// to the [TwoDimensionalScrollView.mainAxis] and shares this value.
Axis get mainAxis => _mainAxis;
Axis _mainAxis;
set mainAxis(Axis value) {
if (_mainAxis == value) {
return;
}
_mainAxis = value;
// Child order needs to be resorted, which happens in performLayout.
markNeedsLayout();
}
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
double get cacheExtent => _cacheExtent ?? RenderAbstractViewport.defaultCacheExtent;
double? _cacheExtent;
set cacheExtent(double? value) {
if (_cacheExtent == value) {
return;
}
_cacheExtent = value;
markNeedsLayout();
}
/// {@macro flutter.material.Material.clipBehavior}
Clip get clipBehavior => _clipBehavior;
Clip _clipBehavior;
set clipBehavior(Clip value) {
if (_clipBehavior == value) {
return;
}
_clipBehavior = value;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
final TwoDimensionalChildManager _childManager;
bool _hasVisualOverflow = false;
final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
@override
bool get isRepaintBoundary => true;
@override
bool get sizedByParent => true;
final Map<ChildVicinity, RenderBox> _children = <ChildVicinity, RenderBox>{};
// Keeps track of the upper and lower bounds of ChildVicinity indices when
// subclasses call buildOrObtainChildFor during layoutChildSequence. These
// values are used to sort children in accordance with the mainAxis for
// paint order.
int? _leadingXIndex;
int? _trailingXIndex;
int? _leadingYIndex;
int? _trailingYIndex;
/// The first child of the viewport according to the traversal order of the
/// [mainAxis].
///
/// {@template flutter.rendering.twoDimensionalViewport.paintOrder}
/// The [mainAxis] correlates with the [ChildVicinity] of each child to paint
/// the children in a row or column major order.
///
/// By default, the [mainAxis] is [Axis.vertical], which would result in a
/// row major paint order, visiting children in the horizontal indices before
/// advancing to the next vertical index.
/// {@endtemplate}
///
/// This value is null during [layoutChildSequence] as children are reified
/// into the correct order after layout is completed. This can be used when
/// overriding [paint] in order to paint the children in the correct order.
RenderBox? get firstChild => _firstChild;
RenderBox? _firstChild;
/// The last child in the viewport according to the traversal order of the
/// [mainAxis].
///
/// {@macro flutter.rendering.twoDimensionalViewport.paintOrder}
///
/// This value is null during [layoutChildSequence] as children are reified
/// into the correct order after layout is completed. This can be used when
/// overriding [paint] in order to paint the children in the correct order.
RenderBox? get lastChild => _lastChild;
RenderBox? _lastChild;
/// The previous child before the given child in the child list according to
/// the traversal order of the [mainAxis].
///
/// {@macro flutter.rendering.twoDimensionalViewport.paintOrder}
///
/// This method is useful when overriding [paint] in order to paint children
/// in the correct order.
RenderBox? childBefore(RenderBox child) {
assert(child.parent == this);
return parentDataOf(child)._previousSibling;
}
/// The next child after the given child in the child list according to
/// the traversal order of the [mainAxis].
///
/// {@macro flutter.rendering.twoDimensionalViewport.paintOrder}
///
/// This method is useful when overriding [paint] in order to paint children
/// in the correct order.
RenderBox? childAfter(RenderBox child) {
assert(child.parent == this);
return parentDataOf(child)._nextSibling;
}
void _handleDelegateNotification() {
return markNeedsLayout(withDelegateRebuild: true);
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! TwoDimensionalViewportParentData) {
child.parentData = TwoDimensionalViewportParentData();
}
}
/// Convenience method for retrieving and casting the [ParentData] of the
/// viewport's children.
///
/// Children must have a [ParentData] of type
/// [TwoDimensionalViewportParentData], or a subclass thereof.
@protected
TwoDimensionalViewportParentData parentDataOf(RenderBox child) {
assert(_children.containsValue(child));
return child.parentData! as TwoDimensionalViewportParentData;
}
/// Returns the active child located at the provided [ChildVicinity], if there
/// is one.
///
/// This can be used by subclasses to access currently active children to make
/// use of their size or [TwoDimensionalViewportParentData], such as when
/// overriding the [paint] method.
///
/// Returns null if there is no active child for the given [ChildVicinity].
@protected
RenderBox? getChildFor(ChildVicinity vicinity) => _children[vicinity];
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_horizontalOffset.addListener(markNeedsLayout);
_verticalOffset.addListener(markNeedsLayout);
_delegate.addListener(_handleDelegateNotification);
for (final RenderBox child in _children.values) {
child.attach(owner);
}
}
@override
void detach() {
super.detach();
_horizontalOffset.removeListener(markNeedsLayout);
_verticalOffset.removeListener(markNeedsLayout);
_delegate.removeListener(_handleDelegateNotification);
for (final RenderBox child in _children.values) {
child.detach();
}
}
@override
void redepthChildren() {
for (final RenderBox child in _children.values) {
child.redepthChildren();
}
}
@override
void visitChildren(RenderObjectVisitor visitor) {
RenderBox? child = _firstChild;
while (child != null) {
visitor(child);
child = parentDataOf(child)._nextSibling;
}
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
// Only children that are visible should be visited, and they must be in
// paint order.
RenderBox? child = _firstChild;
while (child != null) {
final TwoDimensionalViewportParentData childParentData = parentDataOf(child);
if (childParentData.isVisible) {
visitor(child);
}
child = childParentData._nextSibling;
}
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final List<DiagnosticsNode> debugChildren = <DiagnosticsNode>[
..._children.keys.map<DiagnosticsNode>((ChildVicinity vicinity) {
return _children[vicinity]!.toDiagnosticsNode(name: vicinity.toString());
})
];
return debugChildren;
}
@override
Size computeDryLayout(BoxConstraints constraints) {
assert(debugCheckHasBoundedAxis(Axis.vertical, constraints));
assert(debugCheckHasBoundedAxis(Axis.horizontal, constraints));
return constraints.biggest;
}
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
for (final RenderBox child in _children.values) {
final TwoDimensionalViewportParentData childParentData = parentDataOf(child);
if (!childParentData.isVisible) {
// Can't hit a child that is not visible.
continue;
}
final bool isHit = result.addWithPaintOffset(
offset: childParentData.paintOffset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - childParentData.paintOffset!);
return child.hitTest(result, position: transformed);
},
);
if (isHit) {
return true;
}
}
return false;
}
/// The dimensions of the viewport.
///
/// This [Size] represents the width and height of the visible area.
Size get viewportDimension {
assert(hasSize);
return size;
}
@override
void performResize() {
final Size? oldSize = hasSize ? size : null;
super.performResize();
// Ignoring return value since we are doing a layout either way
// (performLayout will be invoked next).
horizontalOffset.applyViewportDimension(size.width);
verticalOffset.applyViewportDimension(size.height);
if (oldSize != size) {
// Specs can depend on viewport size.
_didResize = true;
}
}
@override
RevealedOffset getOffsetToReveal(RenderObject target, double alignment, { Rect? rect }) {
// TODO(Piinks): Add this back in follow up change (ensureVisible), https://github.com/flutter/flutter/issues/126299
return const RevealedOffset(offset: 0.0, rect: Rect.zero);
}
/// Should be used by subclasses to invalidate any cached metrics for the
/// viewport.
///
/// This is set to true when the viewport has been resized, indicating that
/// any cached dimensions are invalid.
///
/// After performLayout, the value is set to false until the viewport
/// dimensions are changed again in [performResize].
///
/// Subclasses are not required to use this value, but it can be used to
/// safely cache layout information in between layout calls.
bool get didResize => _didResize;
bool _didResize = true;
/// Should be used by subclasses to invalidate any cached data from the
/// [delegate].
///
/// This value is set to false after [layoutChildSequence]. If
/// [markNeedsLayout] is called `withDelegateRebuild` set to true, then this
/// value will be updated to true, signifying any cached delegate information
/// needs to be updated in the next call to [layoutChildSequence].
///
/// Subclasses are not required to use this value, but it can be used to
/// safely cache layout information in between layout calls.
@protected
bool get needsDelegateRebuild => _needsDelegateRebuild;
bool _needsDelegateRebuild = true;
@override
void markNeedsLayout({ bool withDelegateRebuild = false }) {
_needsDelegateRebuild = _needsDelegateRebuild || withDelegateRebuild;
super.markNeedsLayout();
}
/// Primary work horse of [performLayout].
///
/// Subclasses must implement this method to layout the children of the
/// viewport. The [TwoDimensionalViewportParentData.layoutOffset] must be set
/// during this method in order for the children to be positioned during paint.
/// Further, children of the viewport must be laid out with the expectation
/// that the parent (this viewport) will use their size.
///
/// ```dart
/// child.layout(constraints, parentUsesSize: true);
/// ```
///
/// The primary methods used for creating and obtaining children is
/// [buildOrObtainChildFor], which takes a [ChildVicinity] that is used by the
/// [TwoDimensionalChildDelegate]. If a child is not provided by the delegate
/// for the provided vicinity, the method will return null, otherwise, it will
/// return the [RenderBox] of the child.
///
/// After [layoutChildSequence] is completed, any remaining children that were
/// not obtained will be disposed.
void layoutChildSequence();
@override
void performLayout() {
_firstChild = null;
_lastChild = null;
_childManager._startLayout();
// Subclass lays out children.
layoutChildSequence();
assert(_debugCheckContentDimensions());
_didResize = false;
_needsDelegateRebuild = false;
invokeLayoutCallback<BoxConstraints>((BoxConstraints _) {
_childManager._endLayout();
assert(_debugOrphans?.isEmpty ?? true);
// Organize children in paint order and complete parent data after
// un-used children are disposed of by the childManager.
_reifyChildren();
});
}
// Ensures all children have a layoutOffset, sets paintExtent & paintOffset,
// and arranges children in paint order.
void _reifyChildren() {
assert(_leadingXIndex != null);
assert(_trailingXIndex != null);
assert(_leadingYIndex != null);
assert(_trailingYIndex != null);
assert(_firstChild == null);
assert(_lastChild == null);
RenderBox? previousChild;
switch (mainAxis) {
case Axis.vertical:
// Row major traversal.
// This seems backwards, but the vertical axis is the typical default
// axis for scrolling in Flutter, while Row-major ordering is the
// typical default for matrices, which is why the inverse follows
// through in the horizontal case below.
// Minor
for (int minorIndex = _leadingYIndex!; minorIndex <= _trailingYIndex!; minorIndex++) {
// Major
for (int majorIndex = _leadingXIndex!; majorIndex <= _trailingXIndex!; majorIndex++) {
final ChildVicinity vicinity = ChildVicinity(xIndex: majorIndex, yIndex: minorIndex);
previousChild = _completeChildParentData(
vicinity,
previousChild: previousChild,
) ?? previousChild;
}
}
case Axis.horizontal:
// Column major traversal
// Minor
for (int minorIndex = _leadingXIndex!; minorIndex <= _trailingXIndex!; minorIndex++) {
// Major
for (int majorIndex = _leadingYIndex!; majorIndex <= _trailingYIndex!; majorIndex++) {
final ChildVicinity vicinity = ChildVicinity(xIndex: minorIndex, yIndex: majorIndex);
previousChild = _completeChildParentData(
vicinity,
previousChild: previousChild,
) ?? previousChild;
}
}
}
_lastChild = previousChild;
parentDataOf(_lastChild!)._nextSibling = null;
// Reset for next layout pass.
_leadingXIndex = null;
_trailingXIndex = null;
_leadingYIndex = null;
_trailingYIndex = null;
}
RenderBox? _completeChildParentData(ChildVicinity vicinity, { RenderBox? previousChild }) {
assert(vicinity != ChildVicinity.invalid);
// It is possible and valid for a vicinity to be skipped.
// For example, a table can have merged cells, spanning multiple
// indices, but only represented by one RenderBox and ChildVicinity.
if (_children.containsKey(vicinity)) {
final RenderBox child = _children[vicinity]!;
assert(parentDataOf(child).vicinity == vicinity);
updateChildPaintData(child);
if (previousChild == null) {
// _firstChild is only set once.
assert(_firstChild == null);
_firstChild = child;
} else {
parentDataOf(previousChild)._nextSibling = child;
parentDataOf(child)._previousSibling = previousChild;
}
return child;
}
return null;
}
bool _debugCheckContentDimensions() {
const String hint = 'Subclasses should call applyContentDimensions on the '
'verticalOffset and horizontalOffset to set the min and max scroll offset. '
'If the contents exceed one or both sides of the viewportDimension, '
'ensure the viewportDimension height or width is subtracted in that axis '
'for the correct extent.';
assert(() {
if (!(verticalOffset as ScrollPosition).hasContentDimensions) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'The verticalOffset was not given content dimensions during '
'layoutChildSequence.'
),
ErrorHint(hint),
]);
}
return true;
}());
assert(() {
if (!(horizontalOffset as ScrollPosition).hasContentDimensions) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'The horizontalOffset was not given content dimensions during '
'layoutChildSequence.'
),
ErrorHint(hint),
]);
}
return true;
}());
return true;
}
/// Returns the child for a given [ChildVicinity].
///
/// This method will build the child if it has not been already, or will reuse
/// it if it already exists.
RenderBox? buildOrObtainChildFor(ChildVicinity vicinity) {
assert(vicinity != ChildVicinity.invalid);
if (_leadingXIndex == null || _trailingXIndex == null || _leadingXIndex == null || _trailingYIndex == null) {
// First child of this layout pass. Set leading and trailing trackers.
_leadingXIndex = vicinity.xIndex;
_trailingXIndex = vicinity.xIndex;
_leadingYIndex = vicinity.yIndex;
_trailingYIndex = vicinity.yIndex;
} else {
// If any of these are still null, we missed a child.
assert(_leadingXIndex != null);
assert(_trailingXIndex != null);
assert(_leadingYIndex != null);
assert(_trailingYIndex != null);
// Update as we go.
_leadingXIndex = math.min(vicinity.xIndex, _leadingXIndex!);
_trailingXIndex = math.max(vicinity.xIndex, _trailingXIndex!);
_leadingYIndex = math.min(vicinity.yIndex, _leadingYIndex!);
_trailingYIndex = math.max(vicinity.yIndex, _trailingYIndex!);
}
if (_needsDelegateRebuild || !_children.containsKey(vicinity)) {
invokeLayoutCallback<BoxConstraints>((BoxConstraints _) {
_childManager._buildChild(vicinity);
});
} else {
_childManager._reuseChild(vicinity);
}
if (!_children.containsKey(vicinity)) {
// There is no child for this vicinity, we may have reached the end of the
// children in one or both of the x/y indices.
return null;
}
assert(_children.containsKey(vicinity));
final RenderBox child = _children[vicinity]!;
parentDataOf(child).vicinity = vicinity;
return child;
}
/// Called after [layoutChildSequence] to compute the
/// [TwoDimensionalViewportParentData.paintOffset] and
/// [TwoDimensionalViewportParentData._paintExtent] of the child.
void updateChildPaintData(RenderBox child) {
final TwoDimensionalViewportParentData childParentData = parentDataOf(child);
assert(
childParentData.layoutOffset != null,
'The child with ChildVicinity(xIndex: ${childParentData.vicinity.xIndex}, '
'yIndex: ${childParentData.vicinity.yIndex}) was not provided a '
'layoutOffset. This should be set during layoutChildSequence, '
'representing the position of the child.'
);
assert(child.hasSize); // Child must have been laid out by now.
// Set paintExtent (and visibility)
childParentData._paintExtent = computeChildPaintExtent(
childParentData.layoutOffset!,
child.size,
);
// Set paintOffset
childParentData.paintOffset = computeAbsolutePaintOffsetFor(
child,
layoutOffset: childParentData.layoutOffset!,
paintExtent: childParentData._paintExtent!,
);
// If the child is partially visible, or not visible at all, there is
// visual overflow.
_hasVisualOverflow = _hasVisualOverflow
|| childParentData.layoutOffset != childParentData._paintExtent
|| !childParentData.isVisible;
}
/// Computes the portion of the child that is visible, assuming that only the
/// region from the [ViewportOffset.pixels] of both dimensions to the
/// [cacheExtent] is visible, and that the relationship between scroll offsets
/// and paint offsets is linear.
///
/// For example, if the [ViewportOffset]s each have a scroll offset of 100 and
/// the arguments to this method describe a child with [layoutOffset] of
/// `Offset(50.0, 50.0)`, with a size of `Size(200.0, 200.0)`, then the
/// returned value would be `Size(150.0, 150.0)`, representing the visible
/// extent of the child.
Size computeChildPaintExtent(Offset layoutOffset, Size childSize) {
if (childSize == Size.zero || childSize.height == 0.0 || childSize.width == 0.0) {
return Size.zero;
}
// Horizontal extent
final double width;
if (layoutOffset.dx < 0.0) {
// The child is positioned beyond the leading edge of the viewport.
if (layoutOffset.dx + childSize.width <= 0.0) {
// The child does not extend into the viewable area, it is not visible.
return Size.zero;
}
// If the child is positioned starting at -50, then the paint extent is
// the width + (-50).
width = layoutOffset.dx + childSize.width;
} else if (layoutOffset.dx >= viewportDimension.width) {
// The child is positioned after the trailing edge of the viewport, also
// not visible.
return Size.zero;
} else {
// The child is positioned within the viewport bounds, but may extend
// beyond it.
assert(layoutOffset.dx >= 0 && layoutOffset.dx < viewportDimension.width);
if (layoutOffset.dx + childSize.width > viewportDimension.width) {
width = viewportDimension.width - layoutOffset.dx;
} else {
assert(layoutOffset.dx + childSize.width <= viewportDimension.width);
width = childSize.width;
}
}
// Vertical extent
final double height;
if (layoutOffset.dy < 0.0) {
// The child is positioned beyond the leading edge of the viewport.
if (layoutOffset.dy + childSize.height <= 0.0) {
// The child does not extend into the viewable area, it is not visible.
return Size.zero;
}
// If the child is positioned starting at -50, then the paint extent is
// the width + (-50).
height = layoutOffset.dy + childSize.height;
} else if (layoutOffset.dy >= viewportDimension.height) {
// The child is positioned after the trailing edge of the viewport, also
// not visible.
return Size.zero;
} else {
// The child is positioned within the viewport bounds, but may extend
// beyond it.
assert(layoutOffset.dy >= 0 && layoutOffset.dy < viewportDimension.height);
if (layoutOffset.dy + childSize.height > viewportDimension.height) {
height = viewportDimension.height - layoutOffset.dy;
} else {
assert(layoutOffset.dy + childSize.height <= viewportDimension.height);
height = childSize.height;
}
}
return Size(width, height);
}
/// The offset at which the given `child` should be painted.
///
/// The returned offset is from the top left corner of the inside of the
/// viewport to the top left corner of the paint coordinate system of the
/// `child`.
///
/// This is useful when the one or both of the axes of the viewport are
/// reversed. The normalized layout offset of the child is used to compute
/// the paint offset in relation to the [verticalAxisDirection] and
/// [horizontalAxisDirection].
@protected
Offset computeAbsolutePaintOffsetFor(
RenderBox child, {
required Offset layoutOffset,
required Size paintExtent,
}) {
assert(hasSize); // this is only usable once we have a size
final double xOffset;
final double yOffset;
switch (verticalAxisDirection) {
case AxisDirection.up:
yOffset = viewportDimension.height - (layoutOffset.dy + paintExtent.height);
case AxisDirection.down:
yOffset = layoutOffset.dy;
case AxisDirection.right:
case AxisDirection.left:
throw Exception('This should not happen');
}
switch (horizontalAxisDirection) {
case AxisDirection.right:
xOffset = layoutOffset.dx;
case AxisDirection.left:
xOffset = viewportDimension.width - (layoutOffset.dx + paintExtent.width);
case AxisDirection.up:
case AxisDirection.down:
throw Exception('This should not happen');
}
return Offset(xOffset, yOffset);
}
@override
void paint(PaintingContext context, Offset offset) {
if (_children.isEmpty) {
return;
}
if (_hasVisualOverflow && clipBehavior != Clip.none) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & viewportDimension,
_paintChildren,
clipBehavior: clipBehavior,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
_paintChildren(context, offset);
}
}
void _paintChildren(PaintingContext context, Offset offset) {
RenderBox? child = _firstChild;
while (child != null) {
final TwoDimensionalViewportParentData childParentData = parentDataOf(child);
if (childParentData.isVisible) {
context.paintChild(child, offset + childParentData.paintOffset!);
}
child = childParentData._nextSibling;
}
}
// ---- Called from _TwoDimensionalViewportElement ----
void _insertChild(RenderBox child, ChildVicinity slot) {
assert(_debugTrackOrphans(newOrphan: _children[slot]));
_children[slot] = child;
adoptChild(child);
}
void _moveChild(RenderBox child, {required ChildVicinity from, required ChildVicinity to}) {
if (_children[from] == child) {
_children.remove(from);
}
assert(_debugTrackOrphans(newOrphan: _children[to], noLongerOrphan: child));
_children[to] = child;
}
void _removeChild(RenderBox child, ChildVicinity slot) {
if (_children[slot] == child) {
_children.remove(slot);
}
assert(_debugTrackOrphans(noLongerOrphan: child));
dropChild(child);
}
List<RenderBox>? _debugOrphans;
// When a child is inserted into a slot currently occupied by another child,
// it becomes an orphan until it is either moved to another slot or removed.
bool _debugTrackOrphans({RenderBox? newOrphan, RenderBox? noLongerOrphan}) {
assert(() {
_debugOrphans ??= <RenderBox>[];
if (newOrphan != null) {
_debugOrphans!.add(newOrphan);
}
if (noLongerOrphan != null) {
_debugOrphans!.remove(noLongerOrphan);
}
return true;
}());
return true;
}
/// Throws an exception saying that the object does not support returning
/// intrinsic dimensions if, in debug mode, we are not in the
/// [RenderObject.debugCheckingIntrinsics] mode.
///
/// This is used by [computeMinIntrinsicWidth] et al because viewports do not
/// generally support returning intrinsic dimensions. See the discussion at
/// [computeMinIntrinsicWidth].
@protected
bool debugThrowIfNotCheckingIntrinsics() {
assert(() {
if (!RenderObject.debugCheckingIntrinsics) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('$runtimeType does not support returning intrinsic dimensions.'),
ErrorDescription(
'Calculating the intrinsic dimensions would require instantiating every child of '
'the viewport, which defeats the point of viewports being lazy.',
),
]);
}
return true;
}());
return true;
}
@override
double computeMinIntrinsicWidth(double height) {
assert(debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicWidth(double height) {
assert(debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMinIntrinsicHeight(double width) {
assert(debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
double computeMaxIntrinsicHeight(double width) {
assert(debugThrowIfNotCheckingIntrinsics());
return 0.0;
}
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
final Offset paintOffset = parentDataOf(child).paintOffset!;
transform.translate(paintOffset.dx, paintOffset.dy);
}
@override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
}
/// A delegate used by [RenderTwoDimensionalViewport] to manage its children.
///
/// [RenderTwoDimensionalViewport] objects reify their children lazily to avoid
/// spending resources on children that are not visible in the viewport. This
/// delegate lets these objects create, reuse and remove children.
abstract class TwoDimensionalChildManager {
void _startLayout();
void _buildChild(ChildVicinity vicinity);
void _reuseChild(ChildVicinity vicinity);
void _endLayout();
}
/// The relative position of a child in a [TwoDimensionalViewport] in relation
/// to other children of the viewport.
///
/// While children can be plotted arbitrarily in two dimensional space, the
/// [ChildVicinity] is used to disambiguate their positions, determining how to
/// traverse the children of the space.
///
/// Combined with the [RenderTwoDimensionalViewport.mainAxis], each child's
/// vicinity determines its paint order among all of the children.
@immutable
class ChildVicinity implements Comparable<ChildVicinity> {
/// Creates a reference to a child in a two dimensional plane, with the
/// [xIndex] and [yIndex] being relative to other children in the viewport.
const ChildVicinity({
required this.xIndex,
required this.yIndex,
}) : assert(xIndex >= -1),
assert(yIndex >= -1);
/// Represents an unassigned child position. The given child may be in the
/// process of moving from one position to another.
static const ChildVicinity invalid = ChildVicinity(xIndex: -1, yIndex: -1);
/// The index of the child in the horizontal axis, relative to neighboring
/// children.
///
/// While children's offset and positioning may not be strictly defined in
/// terms of rows and columns, like a table, [ChildVicinity.xIndex] and
/// [ChildVicinity.yIndex] represents order of traversal in row or column
/// major format.
final int xIndex;
/// The index of the child in the vertical axis, relative to neighboring
/// children.
///
/// While children's offset and positioning may not be strictly defined in
/// terms of rows and columns, like a table, [ChildVicinity.xIndex] and
/// [ChildVicinity.yIndex] represents order of traversal in row or column
/// major format.
final int yIndex;
@override
bool operator ==(Object other) {
return other is ChildVicinity
&& other.xIndex == xIndex
&& other.yIndex == yIndex;
}
@override
int get hashCode => Object.hash(xIndex, yIndex);
@override
int compareTo(ChildVicinity other) {
if (xIndex == other.xIndex) {
return yIndex - other.yIndex;
}
return xIndex - other.xIndex;
}
@override
String toString() {
return '(xIndex: $xIndex, yIndex: $yIndex)';
}
}
...@@ -13,7 +13,8 @@ export 'package:flutter/rendering.dart' show ...@@ -13,7 +13,8 @@ export 'package:flutter/rendering.dart' show
AxisDirection, AxisDirection,
GrowthDirection; GrowthDirection;
/// A widget that is bigger on the inside. /// A widget through which a portion of larger content can be viewed, typically
/// in combination with a [Scrollable].
/// ///
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a /// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
/// subset of its children according to its own dimensions and the given /// subset of its children according to its own dimensions and the given
......
...@@ -149,6 +149,8 @@ export 'src/widgets/ticker_provider.dart'; ...@@ -149,6 +149,8 @@ export 'src/widgets/ticker_provider.dart';
export 'src/widgets/title.dart'; export 'src/widgets/title.dart';
export 'src/widgets/transitions.dart'; export 'src/widgets/transitions.dart';
export 'src/widgets/tween_animation_builder.dart'; export 'src/widgets/tween_animation_builder.dart';
export 'src/widgets/two_dimensional_scroll_view.dart';
export 'src/widgets/two_dimensional_viewport.dart';
export 'src/widgets/undo_history.dart'; export 'src/widgets/undo_history.dart';
export 'src/widgets/unique_widget.dart'; export 'src/widgets/unique_widget.dart';
export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/value_listenable_builder.dart';
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/src/gestures/monodrag.dart';
import 'package:flutter_test/flutter_test.dart';
import 'two_dimensional_utils.dart';
Widget? _testChildBuilder(BuildContext context, ChildVicinity vicinity) {
return SizedBox(
height: 200,
width: 200,
child: Center(child: Text('C${vicinity.xIndex}:R${vicinity.yIndex}')),
);
}
void main() {
group('TwoDimensionalScrollView',() {
testWidgets('asserts the axis directions do not conflict with one another', (WidgetTester tester) async {
final List<Object> exceptions = <Object>[];
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
// Horizontal wrong
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
delegate: TwoDimensionalChildBuilderDelegate(builder: (_, __) => null),
horizontalDetails: const ScrollableDetails.vertical(),
// Horizontal has default const ScrollableDetails.horizontal()
),
));
// Vertical wrong
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
delegate: TwoDimensionalChildBuilderDelegate(builder: (_, __) => null),
verticalDetails: const ScrollableDetails.horizontal(),
// Horizontal has default const ScrollableDetails.horizontal()
),
));
// Both wrong
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
delegate: TwoDimensionalChildBuilderDelegate(builder: (_, __) => null),
verticalDetails: const ScrollableDetails.horizontal(),
horizontalDetails: const ScrollableDetails.vertical(),
),
));
FlutterError.onError = oldHandler;
expect(exceptions.length, 3);
for (final Object exception in exceptions) {
expect(exception, isAssertionError);
expect((exception as AssertionError).message, contains('are not Axis'));
}
}, variant: TargetPlatformVariant.all());
testWidgets('ScrollableDetails.controller can set initial scroll positions, modify within bounds', (WidgetTester tester) async {
final ScrollController verticalController = ScrollController(initialScrollOffset: 100);
final ScrollController horizontalController = ScrollController(initialScrollOffset: 50);
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
delegate: TwoDimensionalChildBuilderDelegate(
builder: _testChildBuilder,
maxXIndex: 99,
maxYIndex: 99,
),
),
));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 100);
expect(verticalController.position.maxScrollExtent, 19400);
expect(horizontalController.position.pixels, 50);
expect(horizontalController.position.maxScrollExtent, 19200);
verticalController.jumpTo(verticalController.position.maxScrollExtent);
horizontalController.jumpTo(horizontalController.position.maxScrollExtent);
await tester.pump();
expect(verticalController.position.pixels, 19400);
expect(horizontalController.position.pixels, 19200);
// Out of bounds
verticalController.jumpTo(verticalController.position.maxScrollExtent + 100);
horizontalController.jumpTo(horizontalController.position.maxScrollExtent + 100);
// Account for varying scroll physics for different platforms (overscroll)
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 19400);
expect(horizontalController.position.pixels, 19200);
}, variant: TargetPlatformVariant.all());
testWidgets('Properly assigns the PrimaryScrollController to the main axis on the correct platform', (WidgetTester tester) async {
late ScrollController controller;
Widget buildForPrimaryScrollController({
bool? explicitPrimary,
Axis mainAxis = Axis.vertical,
bool addControllerConflict = false,
}) {
return MaterialApp(
home: PrimaryScrollController(
controller: controller,
child: SimpleBuilderTableView(
mainAxis: mainAxis,
primary: explicitPrimary,
verticalDetails: ScrollableDetails.vertical(
controller: addControllerConflict && mainAxis == Axis.vertical
? ScrollController()
: null
),
horizontalDetails: ScrollableDetails.horizontal(
controller: addControllerConflict && mainAxis == Axis.horizontal
? ScrollController()
: null
),
delegate: TwoDimensionalChildBuilderDelegate(
builder: _testChildBuilder,
maxXIndex: 99,
maxYIndex: 99,
),
),
),
);
}
// Horizontal default - horizontal never automatically adopts PSC
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
mainAxis: Axis.horizontal,
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isFalse);
}
// Horizontal explicitly true
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
mainAxis: Axis.horizontal,
explicitPrimary: true,
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
// Primary explicitly true is always adopted.
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isTrue);
expect(controller.position.axis, Axis.horizontal);
}
// Horizontal explicitly false
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
mainAxis: Axis.horizontal,
explicitPrimary: false,
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
// Primary explicitly false is never adopted.
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isFalse);
}
// Vertical default
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController());
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
// Mobile platforms inherit the PSC without explicitly setting
// primary
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
expect(controller.hasClients, isTrue);
expect(controller.position.axis, Axis.vertical);
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isFalse);
}
// Vertical explicitly true
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
explicitPrimary: true,
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
// Primary explicitly true is always adopted.
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isTrue);
expect(controller.position.axis, Axis.vertical);
}
// Vertical explicitly false
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
explicitPrimary: false,
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
// Primary explicitly false is never adopted.
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(controller.hasClients, isFalse);
}
// Assertions
final List<Object> exceptions = <Object>[];
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
// Vertical asserts ScrollableDetails.controller has not been provided if
// primary is explicitly set
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
explicitPrimary: true,
addControllerConflict: true,
));
expect(exceptions.length, 1);
expect(exceptions[0], isAssertionError);
expect(
(exceptions[0] as AssertionError).message,
contains('TwoDimensionalScrollView.primary was explicitly set to true'),
);
exceptions.clear();
// Horizontal asserts ScrollableDetails.controller has not been provided
// if primary is explicitly set true
controller = ScrollController();
await tester.pumpWidget(buildForPrimaryScrollController(
mainAxis: Axis.horizontal,
explicitPrimary: true,
addControllerConflict: true,
));
expect(exceptions.length, 1);
expect(exceptions[0], isAssertionError);
expect(
(exceptions[0] as AssertionError).message,
contains('TwoDimensionalScrollView.primary was explicitly set to true'),
);
FlutterError.onError = oldHandler;
}, variant: TargetPlatformVariant.all());
testWidgets('TwoDimensionalScrollable receives the correct details from TwoDimensionalScrollView', (WidgetTester tester) async {
late BuildContext capturedContext;
// Default
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
delegate: TwoDimensionalChildBuilderDelegate(
builder: (BuildContext context, ChildVicinity vicinity) {
capturedContext = context;
return Text(vicinity.toString());
},
),
),
));
await tester.pumpAndSettle();
TwoDimensionalScrollableState scrollable = TwoDimensionalScrollable.of(
capturedContext,
);
expect(scrollable.widget.verticalDetails.direction, AxisDirection.down);
expect(scrollable.widget.horizontalDetails.direction, AxisDirection.right);
expect(scrollable.widget.diagonalDragBehavior, DiagonalDragBehavior.none);
expect(scrollable.widget.dragStartBehavior, DragStartBehavior.start);
// Customized
await tester.pumpWidget(MaterialApp(
home: SimpleBuilderTableView(
verticalDetails: const ScrollableDetails.vertical(reverse: true),
horizontalDetails: const ScrollableDetails.horizontal(reverse: true),
diagonalDragBehavior: DiagonalDragBehavior.weightedContinuous,
dragStartBehavior: DragStartBehavior.down,
delegate: TwoDimensionalChildBuilderDelegate(
builder: _testChildBuilder,
),
),
));
await tester.pumpAndSettle();
scrollable = TwoDimensionalScrollable.of(capturedContext);
expect(scrollable.widget.verticalDetails.direction, AxisDirection.up);
expect(scrollable.widget.horizontalDetails.direction, AxisDirection.left);
expect(scrollable.widget.diagonalDragBehavior, DiagonalDragBehavior.weightedContinuous);
expect(scrollable.widget.dragStartBehavior, DragStartBehavior.down);
}, variant: TargetPlatformVariant.all());
});
}
// Copyright 2014 The Flutter 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/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ViewportOffset;
// BUILDER DELEGATE ---
final TwoDimensionalChildBuilderDelegate builderDelegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
return Container(
color: vicinity.xIndex.isEven && vicinity.yIndex.isEven
? Colors.amber[100]
: (vicinity.xIndex.isOdd && vicinity.yIndex.isOdd
? Colors.blueAccent[100]
: null),
height: 200,
width: 200,
child: Center(child: Text('R${vicinity.xIndex}:C${vicinity.yIndex}')),
);
}
);
// Creates a simple 2D table of 200x200 squares with a builder delegate.
Widget simpleBuilderTest({
Axis mainAxis = Axis.vertical,
bool? primary,
ScrollableDetails? verticalDetails,
ScrollableDetails? horizontalDetails,
TwoDimensionalChildBuilderDelegate? delegate,
double? cacheExtent,
DiagonalDragBehavior? diagonalDrag,
Clip? clipBehavior,
String? restorationID,
bool useCacheExtent = false,
bool applyDimensions = true,
bool forgetToLayoutChild = false,
bool setLayoutOffset = true,
}) {
return MaterialApp(
restorationScopeId: restorationID,
home: Scaffold(
body: SimpleBuilderTableView(
mainAxis: mainAxis,
verticalDetails: verticalDetails ?? const ScrollableDetails.vertical(),
horizontalDetails: horizontalDetails ?? const ScrollableDetails.horizontal(),
cacheExtent: cacheExtent,
useCacheExtent: useCacheExtent,
diagonalDragBehavior: diagonalDrag ?? DiagonalDragBehavior.none,
clipBehavior: clipBehavior ?? Clip.hardEdge,
delegate: delegate ?? builderDelegate,
applyDimensions: applyDimensions,
forgetToLayoutChild: forgetToLayoutChild,
setLayoutOffset: setLayoutOffset,
),
),
);
}
class SimpleBuilderTableView extends TwoDimensionalScrollView {
const SimpleBuilderTableView({
super.key,
super.primary,
super.mainAxis = Axis.vertical,
super.verticalDetails = const ScrollableDetails.vertical(),
super.horizontalDetails = const ScrollableDetails.horizontal(),
required TwoDimensionalChildBuilderDelegate delegate,
super.cacheExtent,
super.diagonalDragBehavior = DiagonalDragBehavior.none,
super.dragStartBehavior = DragStartBehavior.start,
super.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
super.clipBehavior = Clip.hardEdge,
this.useCacheExtent = false,
this.applyDimensions = true,
this.forgetToLayoutChild = false,
this.setLayoutOffset = true,
}) : super(delegate: delegate);
// Piped through for testing in RenderTwoDimensionalViewport
final bool useCacheExtent;
final bool applyDimensions;
final bool forgetToLayoutChild;
final bool setLayoutOffset;
@override
Widget buildViewport(BuildContext context, ViewportOffset verticalOffset, ViewportOffset horizontalOffset) {
return SimpleBuilderTableViewport(
horizontalOffset: horizontalOffset,
horizontalAxisDirection: horizontalDetails.direction,
verticalOffset: verticalOffset,
verticalAxisDirection: verticalDetails.direction,
mainAxis: mainAxis,
delegate: delegate as TwoDimensionalChildBuilderDelegate,
cacheExtent: cacheExtent,
clipBehavior: clipBehavior,
useCacheExtent: useCacheExtent,
applyDimensions: applyDimensions,
forgetToLayoutChild: forgetToLayoutChild,
setLayoutOffset: setLayoutOffset,
);
}
}
class SimpleBuilderTableViewport extends TwoDimensionalViewport {
const SimpleBuilderTableViewport({
super.key,
required super.verticalOffset,
required super.verticalAxisDirection,
required super.horizontalOffset,
required super.horizontalAxisDirection,
required TwoDimensionalChildBuilderDelegate delegate,
required super.mainAxis,
super.cacheExtent,
super.clipBehavior = Clip.hardEdge,
this.useCacheExtent = false,
this.applyDimensions = true,
this.forgetToLayoutChild = false,
this.setLayoutOffset = true,
}) : super(delegate: delegate);
// Piped through for testing in RenderTwoDimensionalViewport
final bool useCacheExtent;
final bool applyDimensions;
final bool forgetToLayoutChild;
final bool setLayoutOffset;
@override
RenderTwoDimensionalViewport createRenderObject(BuildContext context) {
return RenderSimpleBuilderTableViewport(
horizontalOffset: horizontalOffset,
horizontalAxisDirection: horizontalAxisDirection,
verticalOffset: verticalOffset,
verticalAxisDirection: verticalAxisDirection,
mainAxis: mainAxis,
delegate: delegate as TwoDimensionalChildBuilderDelegate,
childManager: context as TwoDimensionalChildManager,
cacheExtent: cacheExtent,
clipBehavior: clipBehavior,
useCacheExtent: useCacheExtent,
applyDimensions: applyDimensions,
forgetToLayoutChild: forgetToLayoutChild,
setLayoutOffset: setLayoutOffset,
);
}
@override
void updateRenderObject(BuildContext context, RenderSimpleBuilderTableViewport renderObject) {
renderObject
..horizontalOffset = horizontalOffset
..horizontalAxisDirection = horizontalAxisDirection
..verticalOffset = verticalOffset
..verticalAxisDirection = verticalAxisDirection
..mainAxis = mainAxis
..delegate = delegate
..cacheExtent = cacheExtent
..clipBehavior = clipBehavior;
}
}
class RenderSimpleBuilderTableViewport extends RenderTwoDimensionalViewport {
RenderSimpleBuilderTableViewport({
required super.horizontalOffset,
required super.horizontalAxisDirection,
required super.verticalOffset,
required super.verticalAxisDirection,
required TwoDimensionalChildBuilderDelegate delegate,
required super.mainAxis,
required super.childManager,
super.cacheExtent,
super.clipBehavior = Clip.hardEdge,
this.applyDimensions = true,
this.setLayoutOffset = true,
this.useCacheExtent = false,
this.forgetToLayoutChild = false,
}) : super(delegate: delegate);
// These are to test conditions to validate subclass implementations after
// layoutChildSequence
final bool applyDimensions;
final bool setLayoutOffset;
final bool useCacheExtent;
final bool forgetToLayoutChild;
RenderBox? testGetChildFor(ChildVicinity vicinity) => getChildFor(vicinity);
@override
void layoutChildSequence() {
// Really simple table implementation for testing.
// Every child is 200x200 square
final double horizontalPixels = horizontalOffset.pixels;
final double verticalPixels = verticalOffset.pixels;
final double viewportWidth = viewportDimension.width + (useCacheExtent ? cacheExtent : 0.0);
final double viewportHeight = viewportDimension.height + (useCacheExtent ? cacheExtent : 0.0);
final TwoDimensionalChildBuilderDelegate builderDelegate = delegate as TwoDimensionalChildBuilderDelegate;
final int maxRowIndex;
final int maxColumnIndex;
maxRowIndex = builderDelegate.maxYIndex ?? 5;
maxColumnIndex = builderDelegate.maxXIndex ?? 5;
final int leadingColumn = math.max((horizontalPixels / 200).floor(), 0);
final int leadingRow = math.max((verticalPixels / 200).floor(), 0);
final int trailingColumn = math.min(
((horizontalPixels + viewportWidth) / 200).ceil(),
maxColumnIndex,
);
final int trailingRow = math.min(
((verticalPixels + viewportHeight) / 200).ceil(),
maxRowIndex,
);
double xLayoutOffset = (leadingColumn * 200) - horizontalOffset.pixels;
for (int column = leadingColumn; column <= trailingColumn; column++) {
double yLayoutOffset = (leadingRow * 200) - verticalOffset.pixels;
for (int row = leadingRow; row <= trailingRow; row++) {
final ChildVicinity vicinity = ChildVicinity(xIndex: column, yIndex: row);
final RenderBox child = buildOrObtainChildFor(vicinity)!;
if (!forgetToLayoutChild) {
child.layout(constraints.tighten(width: 200.0, height: 200.0));
}
if (setLayoutOffset) {
parentDataOf(child).layoutOffset = Offset(xLayoutOffset, yLayoutOffset);
}
yLayoutOffset += 200;
}
xLayoutOffset += 200;
}
if (applyDimensions) {
final double verticalExtent = 200 * (maxRowIndex + 1);
verticalOffset.applyContentDimensions(
0.0,
clampDouble(verticalExtent - viewportDimension.height, 0.0, double.infinity),
);
final double horizontalExtent = 200 * (maxColumnIndex + 1);
horizontalOffset.applyContentDimensions(
0.0,
clampDouble(horizontalExtent - viewportDimension.width, 0.0, double.infinity),
);
}
}
}
// LIST DELEGATE ---
final List<List<Widget>> children = List<List<Widget>>.generate(
100,
(int xIndex) {
return List<Widget>.generate(
100,
(int yIndex) {
return Container(
color: xIndex.isEven && yIndex.isEven
? Colors.amber[100]
: (xIndex.isOdd && yIndex.isOdd
? Colors.blueAccent[100]
: null),
height: 200,
width: 200,
child: Center(child: Text('R$xIndex:C$yIndex')),
);
},
);
},
);
// Builds a simple 2D table of 200x200 squares with a list delegate.
Widget simpleListTest({
Axis mainAxis = Axis.vertical,
bool? primary,
ScrollableDetails? verticalDetails,
ScrollableDetails? horizontalDetails,
TwoDimensionalChildListDelegate? delegate,
double? cacheExtent,
DiagonalDragBehavior? diagonalDrag,
Clip? clipBehavior,
}) {
return MaterialApp(
home: Scaffold(
body: SimpleListTableView(
mainAxis: mainAxis,
verticalDetails: verticalDetails ?? const ScrollableDetails.vertical(),
horizontalDetails: horizontalDetails ?? const ScrollableDetails.horizontal(),
cacheExtent: cacheExtent,
diagonalDragBehavior: diagonalDrag ?? DiagonalDragBehavior.none,
clipBehavior: clipBehavior ?? Clip.hardEdge,
delegate: delegate ?? TwoDimensionalChildListDelegate(children: children),
),
),
);
}
class SimpleListTableView extends TwoDimensionalScrollView {
const SimpleListTableView({
super.key,
super.primary,
super.mainAxis = Axis.vertical,
super.verticalDetails = const ScrollableDetails.vertical(),
super.horizontalDetails = const ScrollableDetails.horizontal(),
required TwoDimensionalChildListDelegate delegate,
super.cacheExtent,
super.diagonalDragBehavior = DiagonalDragBehavior.none,
super.dragStartBehavior = DragStartBehavior.start,
super.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
super.clipBehavior = Clip.hardEdge,
}) : super(delegate: delegate);
@override
Widget buildViewport(BuildContext context, ViewportOffset verticalOffset, ViewportOffset horizontalOffset) {
return SimpleListTableViewport(
horizontalOffset: horizontalOffset,
horizontalAxisDirection: horizontalDetails.direction,
verticalOffset: verticalOffset,
verticalAxisDirection: verticalDetails.direction,
mainAxis: mainAxis,
delegate: delegate as TwoDimensionalChildListDelegate,
cacheExtent: cacheExtent,
clipBehavior: clipBehavior,
);
}
}
class SimpleListTableViewport extends TwoDimensionalViewport {
const SimpleListTableViewport({
super.key,
required super.verticalOffset,
required super.verticalAxisDirection,
required super.horizontalOffset,
required super.horizontalAxisDirection,
required TwoDimensionalChildListDelegate delegate,
required super.mainAxis,
super.cacheExtent,
super.clipBehavior = Clip.hardEdge,
}) : super(delegate: delegate);
@override
RenderTwoDimensionalViewport createRenderObject(BuildContext context) {
return RenderSimpleListTableViewport(
horizontalOffset: horizontalOffset,
horizontalAxisDirection: horizontalAxisDirection,
verticalOffset: verticalOffset,
verticalAxisDirection: verticalAxisDirection,
mainAxis: mainAxis,
delegate: delegate as TwoDimensionalChildListDelegate,
childManager: context as TwoDimensionalChildManager,
cacheExtent: cacheExtent,
clipBehavior: clipBehavior,
);
}
@override
void updateRenderObject(BuildContext context, RenderSimpleListTableViewport renderObject) {
renderObject
..horizontalOffset = horizontalOffset
..horizontalAxisDirection = horizontalAxisDirection
..verticalOffset = verticalOffset
..verticalAxisDirection = verticalAxisDirection
..mainAxis = mainAxis
..delegate = delegate
..cacheExtent = cacheExtent
..clipBehavior = clipBehavior;
}
}
class RenderSimpleListTableViewport extends RenderTwoDimensionalViewport {
RenderSimpleListTableViewport({
required super.horizontalOffset,
required super.horizontalAxisDirection,
required super.verticalOffset,
required super.verticalAxisDirection,
required TwoDimensionalChildListDelegate delegate,
required super.mainAxis,
required super.childManager,
super.cacheExtent,
super.clipBehavior = Clip.hardEdge,
}) : super(delegate: delegate);
@override
void layoutChildSequence() {
// Really simple table implementation for testing.
// Every child is 200x200 square
final double horizontalPixels = horizontalOffset.pixels;
final double verticalPixels = verticalOffset.pixels;
final TwoDimensionalChildListDelegate listDelegate = delegate as TwoDimensionalChildListDelegate;
final int rowCount;
final int columnCount;
rowCount = listDelegate.children.length - 1;
columnCount = listDelegate.children[0].length - 1;
final int leadingColumn = math.max((horizontalPixels / 200).floor(), 0);
final int leadingRow = math.max((verticalPixels / 200).floor(), 0);
final int trailingColumn = math.min(
((horizontalPixels + viewportDimension.width) / 200).ceil(),
columnCount,
);
final int trailingRow = math.min(
((verticalPixels + viewportDimension.height) / 200).ceil(),
rowCount,
);
double xLayoutOffset = (leadingColumn * 200) - horizontalOffset.pixels;
for (int column = leadingColumn; column <= trailingColumn; column++) {
double yLayoutOffset = (leadingRow * 200) - verticalOffset.pixels;
for (int row = leadingRow; row <= trailingRow; row++) {
final ChildVicinity vicinity = ChildVicinity(xIndex: column, yIndex: row);
final RenderBox child = buildOrObtainChildFor(vicinity)!;
child.layout(constraints.tighten(width: 200.0, height: 200.0));
parentDataOf(child).layoutOffset = Offset(xLayoutOffset, yLayoutOffset);
yLayoutOffset += 200;
}
xLayoutOffset += 200;
}
verticalOffset.applyContentDimensions(0, 200 * 100 - viewportDimension.height);
horizontalOffset.applyContentDimensions(0, 200 * 100 - viewportDimension.width);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'two_dimensional_utils.dart';
void main() {
group('TwoDimensionalChildDelegate', () {
group('TwoDimensionalChildBuilderDelegate', () {
testWidgets('repaintBoundaries', (WidgetTester tester) async {
// Default - adds repaint boundaries
await tester.pumpWidget(simpleBuilderTest(
delegate: TwoDimensionalChildBuilderDelegate(
// Only build 1 child
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox(
height: 200,
width: 200,
child: Center(child: Text('C${vicinity.xIndex}:R${vicinity.yIndex}')),
);
}
)
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(RepaintBoundary), findsNWidgets(7));
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(find.byType(RepaintBoundary), findsNWidgets(3));
}
// None
await tester.pumpWidget(simpleBuilderTest(
delegate: TwoDimensionalChildBuilderDelegate(
// Only build 1 child
maxXIndex: 0,
maxYIndex: 0,
addRepaintBoundaries: false,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox(
height: 200,
width: 200,
child: Center(child: Text('C${vicinity.xIndex}:R${vicinity.yIndex}')),
);
}
)
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(RepaintBoundary), findsNWidgets(6));
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(find.byType(RepaintBoundary), findsNWidgets(2));
}
}, variant: TargetPlatformVariant.all());
testWidgets('will return null from build for exceeding maxXIndex and maxYIndex', (WidgetTester tester) async {
late BuildContext capturedContext;
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
// Only build 1 child
maxXIndex: 0,
maxYIndex: 0,
addRepaintBoundaries: false,
builder: (BuildContext context, ChildVicinity vicinity) {
capturedContext = context;
return SizedBox(
height: 200,
width: 200,
child: Center(child: Text('C${vicinity.xIndex}:R${vicinity.yIndex}')),
);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
// maxXIndex
expect(
delegate.build(capturedContext, const ChildVicinity(xIndex: 1, yIndex: 0)),
isNull,
);
// maxYIndex
expect(
delegate.build(capturedContext, const ChildVicinity(xIndex: 0, yIndex: 1)),
isNull,
);
// Both
expect(
delegate.build(capturedContext, const ChildVicinity(xIndex: 1, yIndex: 1)),
isNull,
);
}, variant: TargetPlatformVariant.all());
testWidgets('throws an error when builder throws', (WidgetTester tester) async {
final List<Object> exceptions = <Object>[];
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
// Only build 1 child
maxXIndex: 0,
maxYIndex: 0,
addRepaintBoundaries: false,
builder: (BuildContext context, ChildVicinity vicinity) {
throw 'Builder error!';
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
FlutterError.onError = oldHandler;
expect(exceptions.isNotEmpty, isTrue);
expect(exceptions.length, 1);
expect(exceptions[0] as String, contains('Builder error!'));
}, variant: TargetPlatformVariant.all());
testWidgets('shouldRebuild', (WidgetTester tester) async {
expect(builderDelegate.shouldRebuild(builderDelegate), isTrue);
}, variant: TargetPlatformVariant.all());
});
group('TwoDimensionalChildListDelegate', () {
testWidgets('repaintBoundaries', (WidgetTester tester) async {
final List<List<Widget>> children = <List<Widget>>[];
children.add(<Widget>[
const SizedBox(
height: 200,
width: 200,
child: Center(child: Text('R0:C0')),
)
]);
// Default - adds repaint boundaries
await tester.pumpWidget(simpleListTest(
delegate: TwoDimensionalChildListDelegate(
// Only builds 1 child
children: children,
)
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(RepaintBoundary), findsNWidgets(7));
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(find.byType(RepaintBoundary), findsNWidgets(3));
}
// None
await tester.pumpWidget(simpleListTest(
delegate: TwoDimensionalChildListDelegate(
// Different children triggers rebuild
children: <List<Widget>>[<Widget>[Container()]],
addRepaintBoundaries: false,
)
));
await tester.pumpAndSettle();
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
expect(find.byType(RepaintBoundary), findsNWidgets(6));
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
expect(find.byType(RepaintBoundary), findsNWidgets(2));
}
}, variant: TargetPlatformVariant.all());
testWidgets('will return null for a ChildVicinity outside of list bounds', (WidgetTester tester) async {
final List<List<Widget>> children = <List<Widget>>[];
children.add(<Widget>[
const SizedBox(
height: 200,
width: 200,
child: Center(child: Text('R0:C0')),
)
]);
final TwoDimensionalChildListDelegate delegate = TwoDimensionalChildListDelegate(
// Only builds 1 child
children: children,
);
// X index
expect(
delegate.build(_NullBuildContext(), const ChildVicinity(xIndex: 1, yIndex: 0)),
isNull,
);
// Y index
expect(
delegate.build(_NullBuildContext(), const ChildVicinity(xIndex: 0, yIndex: 1)),
isNull,
);
// Both
expect(
delegate.build(_NullBuildContext(), const ChildVicinity(xIndex: 1, yIndex: 1)),
isNull,
);
}, variant: TargetPlatformVariant.all());
testWidgets('shouldRebuild', (WidgetTester tester) async {
final List<List<Widget>> children = <List<Widget>>[];
children.add(<Widget>[
const SizedBox(
height: 200,
width: 200,
child: Center(child: Text('R0:C0')),
)
]);
final TwoDimensionalChildListDelegate delegate = TwoDimensionalChildListDelegate(
// Only builds 1 child
children: children,
);
expect(delegate.shouldRebuild(delegate), isFalse);
final List<List<Widget>> newChildren = <List<Widget>>[];
final TwoDimensionalChildListDelegate oldDelegate = TwoDimensionalChildListDelegate(
children: newChildren,
);
expect(delegate.shouldRebuild(oldDelegate), isTrue);
}, variant: TargetPlatformVariant.all());
});
});
group('TwoDimensionalScrollable', () {
testWidgets('.of, .maybeOf', (WidgetTester tester) async {
late BuildContext capturedContext;
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
capturedContext = context;
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
expect(TwoDimensionalScrollable.of(capturedContext), isNotNull);
expect(TwoDimensionalScrollable.maybeOf(capturedContext), isNotNull);
await tester.pumpWidget(Builder(
builder: (BuildContext context) {
capturedContext = context;
TwoDimensionalScrollable.of(context);
return Container();
}
));
await tester.pumpAndSettle();
final dynamic exception = tester.takeException();
expect(exception, isFlutterError);
final FlutterError error = exception as FlutterError;
expect(error.toString(), contains(
'TwoDimensionalScrollable.of() was called with a context that does '
'not contain a TwoDimensionalScrollable widget.'
));
expect(TwoDimensionalScrollable.maybeOf(capturedContext), isNull);
}, variant: TargetPlatformVariant.all());
testWidgets('horizontal and vertical getters', (WidgetTester tester) async {
late BuildContext capturedContext;
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
capturedContext = context;
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final TwoDimensionalScrollableState scrollable = TwoDimensionalScrollable.of(capturedContext);
expect(scrollable.verticalScrollable.position.pixels, 0.0);
expect(scrollable.horizontalScrollable.position.pixels, 0.0);
}, variant: TargetPlatformVariant.all());
testWidgets('creates fallback ScrollControllers if not provided by ScrollableDetails', (WidgetTester tester) async {
late BuildContext capturedContext;
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
capturedContext = context;
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
// Vertical
final ScrollableState vertical = Scrollable.of(capturedContext, axis: Axis.vertical);
expect(vertical.widget.controller, isNotNull);
// Horizontal
final ScrollableState horizontal = Scrollable.of(capturedContext, axis: Axis.horizontal);
expect(horizontal.widget.controller, isNotNull);
}, variant: TargetPlatformVariant.all());
testWidgets('asserts the axis directions do not conflict with one another', (WidgetTester tester) async {
final List<Object> exceptions = <Object>[];
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
// Horizontal mismatch
await tester.pumpWidget(TwoDimensionalScrollable(
horizontalDetails: const ScrollableDetails.horizontal(),
verticalDetails: const ScrollableDetails.horizontal(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return Container();
},
));
// Vertical mismatch
await tester.pumpWidget(TwoDimensionalScrollable(
horizontalDetails: const ScrollableDetails.vertical(),
verticalDetails: const ScrollableDetails.vertical(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return Container();
},
));
// Both
await tester.pumpWidget(TwoDimensionalScrollable(
horizontalDetails: const ScrollableDetails.vertical(),
verticalDetails: const ScrollableDetails.horizontal(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return Container();
},
));
expect(exceptions.length, 3);
for (final Object exception in exceptions) {
expect(exception, isAssertionError);
expect((exception as AssertionError).message, contains('are not Axis'));
}
FlutterError.onError = oldHandler;
}, variant: TargetPlatformVariant.all());
testWidgets('correctly sets restorationIds', (WidgetTester tester) async {
late BuildContext capturedContext;
// with restorationID set
await tester.pumpWidget(WidgetsApp(
color: const Color(0xFFFFFFFF),
restorationScopeId: 'Test ID',
builder: (BuildContext context, Widget? child) => TwoDimensionalScrollable(
restorationId: 'Custom Restoration ID',
horizontalDetails: const ScrollableDetails.horizontal(),
verticalDetails: const ScrollableDetails.vertical(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return SizedBox.square(
dimension: 200,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return Container();
},
)
);
},
),
));
await tester.pumpAndSettle();
expect(
RestorationScope.of(capturedContext).restorationId,
'Custom Restoration ID',
);
expect(
Scrollable.of(capturedContext, axis: Axis.vertical).widget.restorationId,
'OuterVerticalTwoDimensionalScrollable',
);
expect(
Scrollable.of(capturedContext, axis: Axis.horizontal).widget.restorationId,
'InnerHorizontalTwoDimensionalScrollable',
);
// default restorationID
await tester.pumpWidget(TwoDimensionalScrollable(
horizontalDetails: const ScrollableDetails.horizontal(),
verticalDetails: const ScrollableDetails.vertical(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return SizedBox.square(
dimension: 200,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return Container();
},
)
);
},
));
await tester.pumpAndSettle();
expect(
RestorationScope.maybeOf(capturedContext),
isNull,
);
expect(
Scrollable.of(capturedContext, axis: Axis.vertical).widget.restorationId,
'OuterVerticalTwoDimensionalScrollable',
);
expect(
Scrollable.of(capturedContext, axis: Axis.horizontal).widget.restorationId,
'InnerHorizontalTwoDimensionalScrollable',
);
}, variant: TargetPlatformVariant.all());
testWidgets('Restoration works', (WidgetTester tester) async {
await tester.pumpWidget(WidgetsApp(
color: const Color(0xFFFFFFFF),
restorationScopeId: 'Test ID',
builder: (BuildContext context, Widget? child) => TwoDimensionalScrollable(
restorationId: 'Custom Restoration ID',
horizontalDetails: const ScrollableDetails.horizontal(),
verticalDetails: const ScrollableDetails.vertical(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return SimpleBuilderTableViewport(
verticalOffset: verticalPosition,
verticalAxisDirection: AxisDirection.down,
horizontalOffset: horizontalPosition,
horizontalAxisDirection: AxisDirection.right,
delegate: builderDelegate,
mainAxis: Axis.vertical,
);
},
),
));
await tester.pumpAndSettle();
await restoreScrollAndVerify(tester);
}, variant: TargetPlatformVariant.all());
testWidgets('Inner Scrollables receive the correct details from TwoDimensionalScrollable', (WidgetTester tester) async {
// Default
late BuildContext capturedContext;
await tester.pumpWidget(TwoDimensionalScrollable(
horizontalDetails: const ScrollableDetails.horizontal(),
verticalDetails: const ScrollableDetails.vertical(),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return SizedBox.square(
dimension: 200,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return Container();
},
)
);
},
));
await tester.pumpAndSettle();
// Vertical
ScrollableState vertical = Scrollable.of(capturedContext, axis: Axis.vertical);
expect(vertical.widget.key, isNotNull);
expect(vertical.widget.axisDirection, AxisDirection.down);
expect(vertical.widget.controller, isNotNull);
expect(vertical.widget.physics, isNull);
expect(vertical.widget.clipBehavior, Clip.hardEdge);
expect(vertical.widget.incrementCalculator, isNull);
expect(vertical.widget.excludeFromSemantics, isFalse);
expect(vertical.widget.restorationId, 'OuterVerticalTwoDimensionalScrollable');
expect(vertical.widget.dragStartBehavior, DragStartBehavior.start);
// Horizontal
ScrollableState horizontal = Scrollable.of(capturedContext, axis: Axis.horizontal);
expect(horizontal.widget.key, isNotNull);
expect(horizontal.widget.axisDirection, AxisDirection.right);
expect(horizontal.widget.controller, isNotNull);
expect(horizontal.widget.physics, isNull);
expect(horizontal.widget.clipBehavior, Clip.hardEdge);
expect(horizontal.widget.incrementCalculator, isNull);
expect(horizontal.widget.excludeFromSemantics, isFalse);
expect(horizontal.widget.restorationId, 'InnerHorizontalTwoDimensionalScrollable');
expect(horizontal.widget.dragStartBehavior, DragStartBehavior.start);
// Customized
final ScrollController horizontalController = ScrollController();
final ScrollController verticalController = ScrollController();
double calculator(_) => 0.0;
await tester.pumpWidget(TwoDimensionalScrollable(
incrementCalculator: calculator,
excludeFromSemantics: true,
dragStartBehavior: DragStartBehavior.down,
horizontalDetails: ScrollableDetails.horizontal(
reverse: true,
controller: horizontalController,
physics: const ClampingScrollPhysics(),
decorationClipBehavior: Clip.antiAlias,
),
verticalDetails: ScrollableDetails.vertical(
reverse: true,
controller: verticalController,
physics: const AlwaysScrollableScrollPhysics(),
decorationClipBehavior: Clip.antiAliasWithSaveLayer,
),
viewportBuilder: (BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition) {
return SizedBox.square(
dimension: 200,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return Container();
},
)
);
},
));
await tester.pumpAndSettle();
// Vertical
vertical = Scrollable.of(capturedContext, axis: Axis.vertical);
expect(vertical.widget.key, isNotNull);
expect(vertical.widget.axisDirection, AxisDirection.up);
expect(vertical.widget.controller, verticalController);
expect(vertical.widget.physics, const AlwaysScrollableScrollPhysics());
expect(vertical.widget.clipBehavior, Clip.antiAliasWithSaveLayer);
expect(
vertical.widget.incrementCalculator!(ScrollIncrementDetails(
type: ScrollIncrementType.line,
metrics: verticalController.position,
)),
0.0,
);
expect(vertical.widget.excludeFromSemantics, isTrue);
expect(vertical.widget.restorationId, 'OuterVerticalTwoDimensionalScrollable');
expect(vertical.widget.dragStartBehavior, DragStartBehavior.down);
// Horizontal
horizontal = Scrollable.of(capturedContext, axis: Axis.horizontal);
expect(horizontal.widget.key, isNotNull);
expect(horizontal.widget.axisDirection, AxisDirection.left);
expect(horizontal.widget.controller, horizontalController);
expect(horizontal.widget.physics, const ClampingScrollPhysics());
expect(horizontal.widget.clipBehavior, Clip.antiAlias);
expect(
horizontal.widget.incrementCalculator!(ScrollIncrementDetails(
type: ScrollIncrementType.line,
metrics: horizontalController.position,
)),
0.0,
);
expect(horizontal.widget.excludeFromSemantics, isTrue);
expect(horizontal.widget.restorationId, 'InnerHorizontalTwoDimensionalScrollable');
expect(horizontal.widget.dragStartBehavior, DragStartBehavior.down);
}, variant: TargetPlatformVariant.all());
group('DiagonalDragBehavior', () {
testWidgets('none (default)', (WidgetTester tester) async {
// Vertical and horizontal axes are locked.
final ScrollController verticalController = ScrollController();
final ScrollController horizontalController = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: simpleBuilderTest(
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
)
));
await tester.pumpAndSettle();
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is TwoDimensionalScrollable);
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
await tester.drag(findScrollable, const Offset(0.0, -100.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 80.0);
expect(horizontalController.position.pixels, 0.0);
await tester.drag(findScrollable, const Offset(-100.0, 0.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 80.0);
expect(horizontalController.position.pixels, 80.0);
// Drag with and x and y offset, only vertical will accept the gesture
// since the x is < kTouchSlop
await tester.drag(findScrollable, const Offset(-10.0, -50.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 110.0);
expect(horizontalController.position.pixels, 80.0);
// Drag with and x and y offset, only horizontal will accept the gesture
// since the y is < kTouchSlop
await tester.drag(findScrollable, const Offset(-50.0, -10.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 110.0);
expect(horizontalController.position.pixels, 110.0);
// Drag with and x and y offset, only vertical will accept the gesture
// x is > kTouchSlop, larger offset wins
await tester.drag(findScrollable, const Offset(-20.0, -50.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 140.0);
expect(horizontalController.position.pixels, 110.0);
// Drag with and x and y offset, only horizontal will accept the gesture
// y is > kTouchSlop, larger offset wins
await tester.drag(findScrollable, const Offset(-50.0, -20.0));
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 140.0);
expect(horizontalController.position.pixels, 140.0);
}, variant: TargetPlatformVariant.all());
testWidgets('weightedEvent', (WidgetTester tester) async {
// For weighted event, the winning axis is locked for the duration of
// the gesture.
final ScrollController verticalController = ScrollController();
final ScrollController horizontalController = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: simpleBuilderTest(
diagonalDrag: DiagonalDragBehavior.weightedEvent,
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
)
));
await tester.pumpAndSettle();
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is TwoDimensionalScrollable);
// Locks to vertical axis - simple.
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
TestGesture gesture = await tester.startGesture(tester.getCenter(findScrollable));
// In this case, the vertical axis clearly wins.
Offset secondLocation = tester.getCenter(findScrollable) + const Offset(0.0, -50.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 0.0);
// Gesture has not ended yet, move with horizontal diff
Offset thirdLocation = secondLocation + const Offset(-30, -15);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
// Only vertical diff applied
expect(verticalController.position.pixels, 65.0);
expect(horizontalController.position.pixels, 0.0);
await gesture.up();
await tester.pumpAndSettle();
// Lock to vertical axis - scrolls diagonally until certain
verticalController.jumpTo(0.0);
horizontalController.jumpTo(0.0);
await tester.pump();
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
gesture = await tester.startGesture(tester.getCenter(findScrollable));
// In this case, the no one clearly wins, so it moves diagonally.
secondLocation = tester.getCenter(findScrollable) + const Offset(-50.0, -50.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 50.0);
// Gesture has not ended yet, move clearly indicating vertical
thirdLocation = secondLocation + const Offset(-20, -50);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
// Only vertical diff applied
expect(verticalController.position.pixels, 100.0);
expect(horizontalController.position.pixels, 50.0);
// Gesture has not ended yet, and vertical axis has won for the gesture
// continue only vertical scrolling.
Offset fourthLocation = thirdLocation + const Offset(-30, -30);
await gesture.moveTo(fourthLocation);
await tester.pumpAndSettle();
// Only vertical diff applied
expect(verticalController.position.pixels, 130.0);
expect(horizontalController.position.pixels, 50.0);
await gesture.up();
await tester.pumpAndSettle();
// Locks to horizontal axis - simple.
verticalController.jumpTo(0.0);
horizontalController.jumpTo(0.0);
await tester.pump();
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
gesture = await tester.startGesture(tester.getCenter(findScrollable));
// In this case, the horizontal axis clearly wins.
secondLocation = tester.getCenter(findScrollable) + const Offset(-50.0, 0.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 50.0);
// Gesture has not ended yet, move with vertical diff
thirdLocation = secondLocation + const Offset(-15, -30);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
// Only vertical diff applied
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 65.0);
await gesture.up();
await tester.pumpAndSettle();
// Lock to horizontal axis - scrolls diagonally until certain
verticalController.jumpTo(0.0);
horizontalController.jumpTo(0.0);
await tester.pump();
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
gesture = await tester.startGesture(tester.getCenter(findScrollable));
// In this case, the no one clearly wins, so it moves diagonally.
secondLocation = tester.getCenter(findScrollable) + const Offset(-50.0, -50.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 50.0);
// Gesture has not ended yet, move clearly indicating horizontal
thirdLocation = secondLocation + const Offset(-50, -20);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
// Only horizontal diff applied
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 100.0);
// Gesture has not ended yet, and horizontal axis has won for the gesture
// continue only horizontal scrolling.
fourthLocation = thirdLocation + const Offset(-30, -30);
await gesture.moveTo(fourthLocation);
await tester.pumpAndSettle();
// Only horizontal diff applied
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 130.0);
await gesture.up();
await tester.pumpAndSettle();
}, variant: TargetPlatformVariant.all());
testWidgets('weightedContinuous', (WidgetTester tester) async {
// For weighted continuous, the winning axis can change if the axis
// differential for the gesture exceeds kTouchSlop. So it can lock, and
// remain locked, if the user maintains a generally straight gesture,
// otherwise it will unlock and re-evaluate.
final ScrollController verticalController = ScrollController();
final ScrollController horizontalController = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: simpleBuilderTest(
diagonalDrag: DiagonalDragBehavior.weightedContinuous,
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
)
));
await tester.pumpAndSettle();
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is TwoDimensionalScrollable);
// Locks to vertical, and then unlocks, resets to horizontal, then
// unlocks and scrolls diagonally.
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
final TestGesture gesture = await tester.startGesture(tester.getCenter(findScrollable));
// In this case, the vertical axis clearly wins.
final Offset secondLocation = tester.getCenter(findScrollable) + const Offset(0.0, -50.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 0.0);
// Gesture has not ended yet, move with horizontal diff, but still
// dominant vertical
final Offset thirdLocation = secondLocation + const Offset(-15, -50);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
// Only vertical diff applied since kTouchSlop was not exceeded in the
// horizontal axis from one drag event to the next.
expect(verticalController.position.pixels, 100.0);
expect(horizontalController.position.pixels, 0.0);
// Gesture has not ended yet, move with unlocking horizontal diff
final Offset fourthLocation = thirdLocation + const Offset(-50, -15);
await gesture.moveTo(fourthLocation);
await tester.pumpAndSettle();
// Only horizontal diff applied
expect(verticalController.position.pixels, 100.0);
expect(horizontalController.position.pixels, 50.0);
// Gesture has not ended yet, move with unlocking diff that results in
// diagonal move since neither wins.
final Offset fifthLocation = fourthLocation + const Offset(-50, -50);
await gesture.moveTo(fifthLocation);
await tester.pumpAndSettle();
// Only horizontal diff applied
expect(verticalController.position.pixels, 150.0);
expect(horizontalController.position.pixels, 100.0);
await gesture.up();
await tester.pumpAndSettle();
}, variant: TargetPlatformVariant.all());
testWidgets('free', (WidgetTester tester) async {
// For free, anything goes.
final ScrollController verticalController = ScrollController();
final ScrollController horizontalController = ScrollController();
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: simpleBuilderTest(
diagonalDrag: DiagonalDragBehavior.free,
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
)
));
await tester.pumpAndSettle();
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is TwoDimensionalScrollable);
// Nothing locks.
expect(verticalController.position.pixels, 0.0);
expect(horizontalController.position.pixels, 0.0);
final TestGesture gesture = await tester.startGesture(tester.getCenter(findScrollable));
final Offset secondLocation = tester.getCenter(findScrollable) + const Offset(0.0, -50.0);
await gesture.moveTo(secondLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 50.0);
expect(horizontalController.position.pixels, 0.0);
final Offset thirdLocation = secondLocation + const Offset(-15, -50);
await gesture.moveTo(thirdLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 100.0);
expect(horizontalController.position.pixels, 15.0);
final Offset fourthLocation = thirdLocation + const Offset(-50, -15);
await gesture.moveTo(fourthLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 115.0);
expect(horizontalController.position.pixels, 65.0);
final Offset fifthLocation = fourthLocation + const Offset(-50, -50);
await gesture.moveTo(fifthLocation);
await tester.pumpAndSettle();
expect(verticalController.position.pixels, 165.0);
expect(horizontalController.position.pixels, 115.0);
await gesture.up();
await tester.pumpAndSettle();
});
});
});
testWidgets('TwoDimensionalViewport asserts against axes mismatch', (WidgetTester tester) async {
// Horizontal mismatch
expect(
() {
SimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.left,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.right,
delegate: builderDelegate,
mainAxis: Axis.vertical,
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
// Vertical mismatch
expect(
() {
SimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.up,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.down,
delegate: builderDelegate,
mainAxis: Axis.vertical,
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
// Both
expect(
() {
SimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.left,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.down,
delegate: builderDelegate,
mainAxis: Axis.vertical,
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
});
test('TwoDimensionalViewportParentData', () {
// Default vicinity is invalid
final TwoDimensionalViewportParentData parentData = TwoDimensionalViewportParentData();
expect(parentData.vicinity, ChildVicinity.invalid);
// toString
parentData
..vicinity = const ChildVicinity(xIndex: 10, yIndex: 10)
..paintOffset = const Offset(20.0, 20.0)
..layoutOffset = const Offset(20.0, 20.0);
expect(
parentData.toString(),
'vicinity=(xIndex: 10, yIndex: 10); layoutOffset=Offset(20.0, 20.0); '
'paintOffset=Offset(20.0, 20.0); not visible ',
);
});
test('ChildVicinity comparable', () {
const ChildVicinity baseVicinity = ChildVicinity(xIndex: 0, yIndex: 0);
const ChildVicinity sameXVicinity = ChildVicinity(xIndex: 0, yIndex: 2);
const ChildVicinity sameYVicinity = ChildVicinity(xIndex: 3, yIndex: 0);
const ChildVicinity sameNothingVicinity = ChildVicinity(xIndex: 20, yIndex: 30);
// ==
expect(baseVicinity == baseVicinity, isTrue);
expect(baseVicinity == sameXVicinity, isFalse);
expect(baseVicinity == sameYVicinity, isFalse);
expect(baseVicinity == sameNothingVicinity, isFalse);
// compareTo
expect(baseVicinity.compareTo(baseVicinity), 0);
expect(baseVicinity.compareTo(sameXVicinity), -2);
expect(baseVicinity.compareTo(sameYVicinity), -3);
expect(baseVicinity.compareTo(sameNothingVicinity), -20);
// toString
expect(baseVicinity.toString(), '(xIndex: 0, yIndex: 0)');
expect(sameXVicinity.toString(), '(xIndex: 0, yIndex: 2)');
expect(sameYVicinity.toString(), '(xIndex: 3, yIndex: 0)');
expect(sameNothingVicinity.toString(), '(xIndex: 20, yIndex: 30)');
});
group('RenderTwoDimensionalViewport', () {
testWidgets('asserts against axes mismatch', (WidgetTester tester) async {
// Horizontal mismatch
expect(
() {
RenderSimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.left,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.right,
delegate: builderDelegate,
mainAxis: Axis.vertical,
childManager: _NullBuildContext(),
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
// Vertical mismatch
expect(
() {
RenderSimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.up,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.down,
delegate: builderDelegate,
mainAxis: Axis.vertical,
childManager: _NullBuildContext(),
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
// Both
expect(
() {
RenderSimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(0.0),
verticalAxisDirection: AxisDirection.left,
horizontalOffset: ViewportOffset.fixed(0.0),
horizontalAxisDirection: AxisDirection.down,
delegate: builderDelegate,
mainAxis: Axis.vertical,
childManager: _NullBuildContext(),
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('AxisDirection is not Axis.'),
),
),
);
});
testWidgets('getters', (WidgetTester tester) async {
final UniqueKey childKey = UniqueKey();
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox.square(key: childKey, dimension: 200);
}
);
final RenderSimpleBuilderTableViewport renderViewport = RenderSimpleBuilderTableViewport(
verticalOffset: ViewportOffset.fixed(10.0),
verticalAxisDirection: AxisDirection.down,
horizontalOffset: ViewportOffset.fixed(20.0),
horizontalAxisDirection: AxisDirection.right,
delegate: delegate,
mainAxis: Axis.vertical,
childManager: _NullBuildContext(),
);
expect(renderViewport.clipBehavior, Clip.hardEdge);
expect(renderViewport.cacheExtent, RenderAbstractViewport.defaultCacheExtent);
expect(renderViewport.isRepaintBoundary, isTrue);
expect(renderViewport.sizedByParent, isTrue);
// No size yet, should assert.
expect(
() {
renderViewport.viewportDimension;
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('hasSize'),
),
),
);
expect(renderViewport.horizontalOffset.pixels, 20.0);
expect(renderViewport.horizontalAxisDirection, AxisDirection.right);
expect(renderViewport.verticalOffset.pixels, 10.0);
expect(renderViewport.verticalAxisDirection, AxisDirection.down);
expect(renderViewport.delegate, delegate);
expect(renderViewport.mainAxis, Axis.vertical);
// viewportDimension when hasSize
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final RenderTwoDimensionalViewport viewport = getViewport(tester, childKey);
expect(viewport.viewportDimension, const Size(800.0, 600.0));
}, variant: TargetPlatformVariant.all());
testWidgets('Children are organized according to mainAxis', (WidgetTester tester) async {
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
TwoDimensionalViewportParentData parentDataOf(RenderBox child) {
return child.parentData! as TwoDimensionalViewportParentData;
}
// mainAxis is vertical (default)
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKeys.values.first,
);
expect(viewport.mainAxis, Axis.vertical);
// first child
expect(
parentDataOf(viewport.firstChild!).vicinity,
const ChildVicinity(xIndex: 0, yIndex: 0),
);
expect(
parentDataOf(viewport.childAfter(viewport.firstChild!)!).vicinity,
const ChildVicinity(xIndex: 1, yIndex: 0),
);
expect(
viewport.childBefore(viewport.firstChild!),
isNull,
);
// last child
expect(
parentDataOf(viewport.lastChild!).vicinity,
const ChildVicinity(xIndex: 4, yIndex: 3),
);
expect(
viewport.childAfter(viewport.lastChild!),
isNull,
);
expect(
parentDataOf(viewport.childBefore(viewport.lastChild!)!).vicinity,
const ChildVicinity(xIndex: 3, yIndex: 3),
);
// mainAxis is horizontal
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
mainAxis: Axis.horizontal,
));
await tester.pumpAndSettle();
viewport = getViewport(tester, childKeys.values.first);
expect(viewport.mainAxis, Axis.horizontal);
// first child
expect(
parentDataOf(viewport.firstChild!).vicinity,
const ChildVicinity(xIndex: 0, yIndex: 0),
);
expect(
parentDataOf(viewport.childAfter(viewport.firstChild!)!).vicinity,
const ChildVicinity(xIndex: 0, yIndex: 1),
);
expect(
viewport.childBefore(viewport.firstChild!),
isNull,
);
// last child
expect(
parentDataOf(viewport.lastChild!).vicinity,
const ChildVicinity(xIndex: 4, yIndex: 3),
);
expect(
viewport.childAfter(viewport.lastChild!),
isNull,
);
expect(
parentDataOf(viewport.childBefore(viewport.lastChild!)!).vicinity,
const ChildVicinity(xIndex: 4, yIndex: 2),
);
}, variant: TargetPlatformVariant.all());
testWidgets('sets up parent data', (WidgetTester tester) async {
// Also tests computeChildPaintOffset & computeChildPaintExtent
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
// parent data is TwoDimensionalViewportParentData
TwoDimensionalViewportParentData parentDataOf(RenderBox child) {
return child.parentData! as TwoDimensionalViewportParentData;
}
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
useCacheExtent: true,
));
await tester.pumpAndSettle();
RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKeys.values.first,
);
// first child
// parentData is computed correctly - normal axes
// - layoutOffset, paintOffset, isVisible, ChildVicinity
TwoDimensionalViewportParentData childParentData = parentDataOf(viewport.firstChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 0, yIndex: 0));
expect(childParentData.isVisible, isTrue);
expect(childParentData.paintOffset, Offset.zero);
expect(childParentData.layoutOffset, Offset.zero);
// The last child is in the cache extent, and should not be visible.
childParentData = parentDataOf(viewport.lastChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 5, yIndex: 5));
expect(childParentData.isVisible, isFalse);
expect(childParentData.paintOffset, const Offset(1000.0, 1000.0));
expect(childParentData.layoutOffset, const Offset(1000.0, 1000.0));
// parentData is computed correctly - reverse axes
// - vertical reverse
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
verticalDetails: const ScrollableDetails.vertical(reverse: true),
));
await tester.pumpAndSettle();
viewport = getViewport(tester, childKeys.values.first);
childParentData = parentDataOf(viewport.firstChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 0, yIndex: 0));
expect(childParentData.isVisible, isTrue);
expect(childParentData.paintOffset, const Offset(0.0, 400.0));
expect(childParentData.layoutOffset, Offset.zero);
// The last child is in the cache extent, and should not be visible.
childParentData = parentDataOf(viewport.lastChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 5, yIndex: 5));
expect(childParentData.isVisible, isFalse);
expect(childParentData.paintOffset, const Offset(1000.0, -400.0));
expect(childParentData.layoutOffset, const Offset(1000.0, 1000.0));
// - horizontal reverse
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
horizontalDetails: const ScrollableDetails.horizontal(reverse: true),
));
await tester.pumpAndSettle();
viewport = getViewport(tester, childKeys.values.first);
childParentData = parentDataOf(viewport.firstChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 0, yIndex: 0));
expect(childParentData.isVisible, isTrue);
expect(childParentData.paintOffset, const Offset(600.0, 0.0));
expect(childParentData.layoutOffset, Offset.zero);
// The last child is in the cache extent, and should not be visible.
childParentData = parentDataOf(viewport.lastChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 5, yIndex: 5));
expect(childParentData.isVisible, isFalse);
expect(childParentData.paintOffset, const Offset(-200.0, 1000.0));
expect(childParentData.layoutOffset, const Offset(1000.0, 1000.0));
// - both reverse
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
horizontalDetails: const ScrollableDetails.horizontal(reverse: true),
verticalDetails: const ScrollableDetails.vertical(reverse: true),
));
await tester.pumpAndSettle();
viewport = getViewport(tester, childKeys.values.first);
childParentData = parentDataOf(viewport.firstChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 0, yIndex: 0));
expect(childParentData.isVisible, isTrue);
expect(childParentData.paintOffset, const Offset(600.0, 400.0));
expect(childParentData.layoutOffset, Offset.zero);
// The last child is in the cache extent, and should not be visible.
childParentData = parentDataOf(viewport.lastChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 5, yIndex: 5));
expect(childParentData.isVisible, isFalse);
expect(childParentData.paintOffset, const Offset(-200.0, -400.0));
expect(childParentData.layoutOffset, const Offset(1000.0, 1000.0));
// Change the scroll positions to test partially visible.
final ScrollController verticalController = ScrollController();
final ScrollController horizontalController = ScrollController();
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
horizontalDetails: ScrollableDetails.horizontal(controller: horizontalController),
verticalDetails: ScrollableDetails.vertical(controller: verticalController),
));
await tester.pumpAndSettle();
verticalController.jumpTo(50.0);
horizontalController.jumpTo(50.0);
await tester.pump();
viewport = getViewport(tester, childKeys.values.first);
childParentData = parentDataOf(viewport.firstChild!);
expect(childParentData.vicinity, const ChildVicinity(xIndex: 0, yIndex: 0));
expect(childParentData.isVisible, isTrue);
expect(childParentData.paintOffset, const Offset(-50.0, -50.0));
expect(childParentData.layoutOffset, const Offset(-50.0, -50.0));
}, variant: TargetPlatformVariant.all());
testWidgets('debugDescribeChildren', (WidgetTester tester) async {
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKeys.values.first,
);
final List<DiagnosticsNode> result = viewport.debugDescribeChildren();
expect(result.length, 20);
expect(
result.first.toString(),
equalsIgnoringHashCodes('(xIndex: 0, yIndex: 0): RenderRepaintBoundary#00000'),
);
expect(
result.last.toString(),
equalsIgnoringHashCodes('(xIndex: 4, yIndex: 3): RenderRepaintBoundary#00000 NEEDS-PAINT'),
);
}, variant: TargetPlatformVariant.all());
testWidgets('asserts that both axes are bounded', (WidgetTester tester) async {
final List<Object> exceptions = <Object>[];
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
// Compose unbounded - vertical axis
await tester.pumpWidget(WidgetsApp(
color: const Color(0xFFFFFFFF),
builder: (BuildContext context, Widget? child) => Column(
children: <Widget>[
SimpleBuilderTableView(delegate: builderDelegate)
]
),
));
await tester.pumpAndSettle();
FlutterError.onError = oldHandler;
expect(exceptions.isNotEmpty, isTrue);
expect((exceptions[0] as FlutterError).message, contains('unbounded'));
exceptions.clear();
FlutterError.onError = (FlutterErrorDetails details) {
exceptions.add(details.exception);
};
// Compose unbounded - horizontal axis
await tester.pumpWidget(WidgetsApp(
color: const Color(0xFFFFFFFF),
builder: (BuildContext context, Widget? child) => Row(
children: <Widget>[
SimpleBuilderTableView(delegate: builderDelegate)
]
),
));
await tester.pumpAndSettle();
FlutterError.onError = oldHandler;
expect(exceptions.isNotEmpty, isTrue);
expect((exceptions[0] as FlutterError).message, contains('unbounded'));
}, variant: TargetPlatformVariant.all());
testWidgets('computeDryLayout asserts axes are bounded', (WidgetTester tester) async {
final UniqueKey childKey = UniqueKey();
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox.square(key: childKey, dimension: 200);
}
);
// Call computeDryLayout with unbounded constraints
await tester.pumpWidget(simpleBuilderTest(delegate: delegate));
final RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKey,
);
expect(
() {
viewport.computeDryLayout(const BoxConstraints());
},
throwsA(
isA<FlutterError>().having(
(FlutterError error) => error.message,
'error.message',
contains('unbounded'),
),
),
);
}, variant: TargetPlatformVariant.all());
testWidgets('correctly resizes dimensions', (WidgetTester tester) async {
final UniqueKey childKey = UniqueKey();
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox.square(key: childKey, dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKey,
);
expect(viewport.viewportDimension, const Size(800.0, 600.0));
tester.view.physicalSize = const Size(300.0, 300.0);
tester.view.devicePixelRatio = 1;
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
viewport = getViewport(tester, childKey);
expect(viewport.viewportDimension, const Size(300.0, 300.0));
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
}, variant: TargetPlatformVariant.all());
testWidgets('Rebuilds when delegate changes', (WidgetTester tester) async {
final UniqueKey firstChildKey = UniqueKey();
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
addRepaintBoundaries: false,
builder: (BuildContext context, ChildVicinity vicinity) {
return SizedBox.square(key: firstChildKey, dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
RenderTwoDimensionalViewport viewport = getViewport(tester, firstChildKey);
expect(viewport.firstChild, tester.renderObject<RenderBox>(find.byKey(firstChildKey)));
// New delegate
final UniqueKey newChildKey = UniqueKey();
final TwoDimensionalChildBuilderDelegate newDelegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 0,
maxYIndex: 0,
addRepaintBoundaries: false,
builder: (BuildContext context, ChildVicinity vicinity) {
return Container(key: newChildKey, height: 300, width: 300, color: const Color(0xFFFFFFFF));
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: newDelegate,
));
viewport = getViewport(tester, newChildKey);
expect(firstChildKey, isNot(newChildKey));
expect(find.byKey(firstChildKey), findsNothing);
expect(find.byKey(newChildKey), findsOneWidget);
expect(viewport.firstChild, tester.renderObject<RenderBox>(find.byKey(newChildKey)));
}, variant: TargetPlatformVariant.all());
testWidgets('hitTestChildren', (WidgetTester tester) async {
final List<ChildVicinity> taps = <ChildVicinity>[];
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 19,
maxYIndex: 19,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(
dimension: 200,
child: Center(
child: FloatingActionButton(
key: childKeys[vicinity],
onPressed: () {
taps.add(vicinity);
},
),
),
);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
useCacheExtent: true, // Untappable children are rendered in the cache extent
));
await tester.pumpAndSettle();
// Regular orientation
// Offset at center of first child
await tester.tapAt(const Offset(100.0, 100.0));
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 0, yIndex: 0)), isTrue);
// Offset by child location
await tester.tap(find.byKey(childKeys[const ChildVicinity(xIndex: 2, yIndex: 2)]!));
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 2, yIndex: 2)), isTrue);
// Offset out of bounds
await tester.tap(
find.byKey(childKeys[const ChildVicinity(xIndex: 5, yIndex: 5)]!),
warnIfMissed: false,
);
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 5, yIndex: 5)), isFalse);
// Reversed
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
verticalDetails: const ScrollableDetails.vertical(reverse: true),
horizontalDetails: const ScrollableDetails.horizontal(reverse: true),
useCacheExtent: true, // Untappable children are rendered in the cache extent
));
await tester.pumpAndSettle();
// Offset at center of first child
await tester.tapAt(const Offset(700.0, 500.0));
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 0, yIndex: 0)), isTrue);
// Offset by child location
await tester.tap(find.byKey(childKeys[const ChildVicinity(xIndex: 2, yIndex: 2)]!));
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 2, yIndex: 2)), isTrue);
// Offset out of bounds
await tester.tap(
find.byKey(childKeys[const ChildVicinity(xIndex: 5, yIndex: 5)]!),
warnIfMissed: false,
);
await tester.pump();
expect(taps.contains(const ChildVicinity(xIndex: 5, yIndex: 5)), isFalse);
}, variant: TargetPlatformVariant.all());
testWidgets('getChildFor', (WidgetTester tester) async {
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final RenderSimpleBuilderTableViewport viewport = getViewport(
tester, childKeys.values.first,
) as RenderSimpleBuilderTableViewport;
// returns child
expect(
viewport.testGetChildFor(const ChildVicinity(xIndex: 0, yIndex: 0)),
isNotNull,
);
expect(
viewport.testGetChildFor(const ChildVicinity(xIndex: 0, yIndex: 0)),
viewport.firstChild,
);
// returns null
expect(
viewport.testGetChildFor(const ChildVicinity(xIndex: 10, yIndex: 10)),
isNull,
);
}, variant: TargetPlatformVariant.all());
testWidgets('asserts vicinity is valid when children are asked to build', (WidgetTester tester) async {
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKeys.values.first,
);
expect(
() {
viewport.buildOrObtainChildFor(ChildVicinity.invalid);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('ChildVicinity.invalid'),
),
),
);
}, variant: TargetPlatformVariant.all());
testWidgets('asserts that content dimensions have been applied', (WidgetTester tester) async {
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
// Will cause the test implementation to not set dimensions
applyDimensions: false,
));
final FlutterError error = tester.takeException() as FlutterError;
expect(error.message, contains('was not given content dimensions'));
}, variant: TargetPlatformVariant.all());
testWidgets('will not rebuild a child if it can be reused', (WidgetTester tester) async {
final List<ChildVicinity> builtChildren = <ChildVicinity>[];
final ScrollController controller = ScrollController();
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
builtChildren.add(vicinity);
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
verticalDetails: ScrollableDetails.vertical(controller: controller),
));
expect(controller.position.pixels, 0.0);
expect(builtChildren.length, 20);
expect(builtChildren[0], const ChildVicinity(xIndex: 0, yIndex: 0));
builtChildren.clear();
controller.jumpTo(1.0); // Move slightly to trigger another layout
await tester.pump();
expect(controller.position.pixels, 1.0);
expect(builtChildren.length, 5); // Next row of children was built
// Children from the first layout pass were re-used, not rebuilt.
expect(
builtChildren.contains(const ChildVicinity(xIndex: 0, yIndex: 0)),
isFalse,
);
}, variant: TargetPlatformVariant.all());
testWidgets('asserts the layoutOffset has been set by the subclass', (WidgetTester tester) async {
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
// Will cause the test implementation to not set the layoutOffset of
// the parent data
setLayoutOffset: false,
));
final AssertionError error = tester.takeException() as AssertionError;
expect(error.message, contains('was not provided a layoutOffset'));
}, variant: TargetPlatformVariant.all());
testWidgets('asserts the children have a size after layoutChildSequence', (WidgetTester tester) async {
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
return const SizedBox.square(dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
// Will cause the test implementation to not actually layout the
// children it asked for.
forgetToLayoutChild: true,
));
final AssertionError error = tester.takeException() as AssertionError;
expect(error.toString(), contains('child.hasSize'));
}, variant: TargetPlatformVariant.all());
testWidgets('does not support intrinsics', (WidgetTester tester) async {
final Map<ChildVicinity, UniqueKey> childKeys = <ChildVicinity, UniqueKey>{};
final TwoDimensionalChildBuilderDelegate delegate = TwoDimensionalChildBuilderDelegate(
maxXIndex: 5,
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
childKeys[vicinity] = UniqueKey();
return SizedBox.square(key: childKeys[vicinity], dimension: 200);
}
);
await tester.pumpWidget(simpleBuilderTest(
delegate: delegate,
));
await tester.pumpAndSettle();
final RenderTwoDimensionalViewport viewport = getViewport(
tester,
childKeys.values.first,
);
expect(
() {
viewport.computeMinIntrinsicWidth(100);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('does not support returning intrinsic dimensions'),
),
),
);
expect(
() {
viewport.computeMaxIntrinsicWidth(100);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('does not support returning intrinsic dimensions'),
),
),
);
expect(
() {
viewport.computeMinIntrinsicHeight(100);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('does not support returning intrinsic dimensions'),
),
),
);
expect(
() {
viewport.computeMaxIntrinsicHeight(100);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'description',
contains('does not support returning intrinsic dimensions'),
),
),
);
}, variant: TargetPlatformVariant.all());
});
}
RenderTwoDimensionalViewport getViewport(WidgetTester tester, Key childKey) {
return RenderAbstractViewport.of(
tester.renderObject(find.byKey(childKey))
) as RenderSimpleBuilderTableViewport;
}
class _NullBuildContext implements BuildContext, TwoDimensionalChildManager {
@override
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
}
Future<void> restoreScrollAndVerify(WidgetTester tester) async {
final Finder findScrollable = find.byElementPredicate((Element e) => e.widget is TwoDimensionalScrollable);
tester.state<TwoDimensionalScrollableState>(findScrollable).horizontalScrollable.position.jumpTo(100);
tester.state<TwoDimensionalScrollableState>(findScrollable).verticalScrollable.position.jumpTo(100);
await tester.pump();
await tester.restartAndRestore();
expect(
tester.state<TwoDimensionalScrollableState>(findScrollable).horizontalScrollable.position.pixels,
100.0,
);
expect(
tester.state<TwoDimensionalScrollableState>(findScrollable).verticalScrollable.position.pixels,
100.0,
);
final TestRestorationData data = await tester.getRestorationData();
tester.state<TwoDimensionalScrollableState>(findScrollable).horizontalScrollable.position.jumpTo(0);
tester.state<TwoDimensionalScrollableState>(findScrollable).verticalScrollable.position.jumpTo(0);
await tester.pump();
await tester.restoreFrom(data);
expect(
tester.state<TwoDimensionalScrollableState>(findScrollable).horizontalScrollable.position.pixels,
100.0,
);
expect(
tester.state<TwoDimensionalScrollableState>(findScrollable).verticalScrollable.position.pixels,
100.0,
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment