diff --git a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart index bdf62c603b16..611d3e55e640 100644 --- a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart +++ b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart @@ -1121,11 +1121,18 @@ class RenderListWheelViewport } @override - RevealedOffset getOffsetToReveal(RenderObject target, double alignment, { Rect? rect }) { + RevealedOffset getOffsetToReveal( + RenderObject target, + double alignment, { + Rect? rect, + Axis? axis, + }) { + // One dimensional viewport has only one axis, it should match if it has + // been provided. + assert(axis == null || axis == Axis.vertical); // `target` is only fully revealed when in the selected/center position. Therefore, // this method always returns the offset that shows `target` in the center position, // which is the same offset for all `alignment` values. - rect ??= target.paintBounds; // `child` will be the last RenderObject before the viewport when walking up from `target`. diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 4e04e9b64372..aa2dd97b9c5c 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -108,10 +108,22 @@ abstract interface class RenderAbstractViewport extends RenderObject { /// when the offset of the viewport is changed by x then `target` also moves /// by x within the viewport. /// + /// The optional [Axis] is used by + /// [RenderTwoDimensionalViewport.getOffsetToReveal] to + /// determine which of the two axes to compute an offset for. One dimensional + /// subclasses like [RenderViewportBase] and [RenderListWheelViewport] will + /// assert in debug builds if the `axis` value is provided and does not match + /// the single [Axis] that viewport is configured for. + /// /// See also: /// /// * [RevealedOffset], which describes the return value of this method. - RevealedOffset getOffsetToReveal(RenderObject target, double alignment, { Rect? rect }); + RevealedOffset getOffsetToReveal( + RenderObject target, + double alignment, { + Rect? rect, + Axis? axis, + }); /// The default value for the cache extent of the viewport. /// @@ -169,6 +181,56 @@ class RevealedOffset { /// value for a specific element. final Rect rect; + /// Determines which provided leading or trailing edge of the viewport, as + /// [RevealedOffset]s, will be used for [RenderViewportBase.showInViewport] + /// accounting for the size and already visible portion of the [RenderObject] + /// that is being revealed. + /// + /// Also used by [RenderTwoDimensionalViewport.showInViewport] for each + /// horizontal and vertical [Axis]. + /// + /// If the target [RenderObject] is already fully visible, this will return + /// null. + static RevealedOffset? clampOffset({ + required RevealedOffset leadingEdgeOffset, + required RevealedOffset trailingEdgeOffset, + required double currentOffset, + }) { + // scrollOffset + // 0 +---------+ + // | | + // _ | | + // viewport position | | | + // with `descendant` at | | | _ + // trailing edge |_ | xxxxxxx | | viewport position + // | | | with `descendant` at + // | | _| leading edge + // | | + // 800 +---------+ + // + // `trailingEdgeOffset`: Distance from scrollOffset 0 to the start of the + // viewport on the left in image above. + // `leadingEdgeOffset`: Distance from scrollOffset 0 to the start of the + // viewport on the right in image above. + // + // The viewport position on the left is achieved by setting `offset.pixels` + // to `trailingEdgeOffset`, the one on the right by setting it to + // `leadingEdgeOffset`. + final bool inverted = leadingEdgeOffset.offset < trailingEdgeOffset.offset; + final RevealedOffset smaller; + final RevealedOffset larger; + (smaller, larger) = inverted + ? (leadingEdgeOffset, trailingEdgeOffset) + : (trailingEdgeOffset, leadingEdgeOffset); + if (currentOffset > larger.offset) { + return larger; + } else if (currentOffset < smaller.offset) { + return smaller; + } else { + return null; + } + } + @override String toString() { return '${objectRuntimeType(this, 'RevealedOffset')}(offset: $offset, rect: $rect)'; @@ -753,7 +815,17 @@ abstract class RenderViewportBase leadingEdgeOffset.offset) { - // `descendant` currently starts above the leading edge and can be shown - // fully on screen by scrolling down (which means: moving viewport up). - targetOffset = leadingEdgeOffset; - } else if (currentOffset < trailingEdgeOffset.offset) { - // `descendant currently ends below the trailing edge and can be shown - // fully on screen by scrolling up (which means: moving viewport down) - targetOffset = trailingEdgeOffset; - } else { + final RevealedOffset? targetOffset = RevealedOffset.clampOffset( + leadingEdgeOffset: leadingEdgeOffset, + trailingEdgeOffset: trailingEdgeOffset, + currentOffset: currentOffset, + ); + if (targetOffset == null) { // `descendant` is between leading and trailing edge and hence already // fully shown on screen. No action necessary. assert(viewport.parent != null); @@ -1209,7 +1249,6 @@ abstract class RenderViewportBase pixels) { target = pixels; } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 88bd11577145..e017d77aa44b 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -44,6 +44,13 @@ typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset p /// which the scrollable content is displayed. typedef TwoDimensionalViewportBuilder = Widget Function(BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition); +// The return type of _performEnsureVisible. +// +// The list of futures represents each pending ScrollPosition call to +// ensureVisible. The returned ScrollableState's context is used to find the +// next potential ancestor Scrollable. +typedef _EnsureVisibleResults = (List>, ScrollableState); + /// A widget that manages scrolling in one dimension and informs the [Viewport] /// through which the content is viewed. /// @@ -441,6 +448,10 @@ class Scrollable extends StatefulWidget { /// Scrolls the scrollables that enclose the given context so as to make the /// given context visible. + /// + /// If the [Scrollable] of the provided [BuildContext] is a + /// [TwoDimensionalScrollable], both vertical and horizontal axes will ensure + /// the target is made visible. static Future ensureVisible( BuildContext context, { double alignment = 0.0, @@ -459,14 +470,16 @@ class Scrollable extends StatefulWidget { RenderObject? targetRenderObject; ScrollableState? scrollable = Scrollable.maybeOf(context); while (scrollable != null) { - futures.add(scrollable.position.ensureVisible( + final List> newFutures; + (newFutures, scrollable) = scrollable._performEnsureVisible( context.findRenderObject()!, alignment: alignment, duration: duration, curve: curve, alignmentPolicy: alignmentPolicy, targetRenderObject: targetRenderObject, - )); + ); + futures.addAll(newFutures); targetRenderObject = targetRenderObject ?? context.findRenderObject(); context = scrollable.context; @@ -1011,6 +1024,28 @@ class ScrollableState extends State with TickerProviderStateMixin, R return result; } + // Returns the Future from calling ensureVisible for the ScrollPosition, as + // as well as this ScrollableState instance so its context can be used to + // check for other ancestor Scrollables in executing ensureVisible. + _EnsureVisibleResults _performEnsureVisible( + RenderObject object, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, + }) { + final Future ensureVisibleFuture = position.ensureVisible( + object, + alignment: alignment, + duration: duration, + curve: curve, + alignmentPolicy: alignmentPolicy, + targetRenderObject: targetRenderObject, + ); + return (>[ ensureVisibleFuture ], this); + } + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -2040,6 +2075,25 @@ class _VerticalOuterDimension extends Scrollable { class _VerticalOuterDimensionState extends ScrollableState { DiagonalDragBehavior get diagonalDragBehavior => (widget as _VerticalOuterDimension).diagonalDragBehavior; + // Implemented in the _HorizontalInnerDimension instead. + @override + _EnsureVisibleResults _performEnsureVisible( + RenderObject object, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, + }) { + assert( + false, + 'The _performEnsureVisible method was called for the vertical scrollable ' + 'of a TwoDimensionalScrollable. This should not happen as the horizontal ' + 'scrollable handles both axes.' + ); + return (>[], this); + } + @override void setCanDrag(bool value) { switch (diagonalDragBehavior) { @@ -2119,6 +2173,39 @@ class _HorizontalInnerDimensionState extends ScrollableState { super.didChangeDependencies(); } + // Returns the Future from calling ensureVisible for the ScrollPosition, as + // as well as the vertical ScrollableState instance so its context can be + // used to check for other ancestor Scrollables in executing ensureVisible. + @override + _EnsureVisibleResults _performEnsureVisible( + RenderObject object, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, + }) { + final List> newFutures = >[]; + + newFutures.add(position.ensureVisible( + object, + alignment: alignment, + duration: duration, + curve: curve, + alignmentPolicy: alignmentPolicy, + )); + + newFutures.add(verticalScrollable.position.ensureVisible( + object, + alignment: alignment, + duration: duration, + curve: curve, + alignmentPolicy: alignmentPolicy, + )); + + return (newFutures, verticalScrollable); + } + void _evaluateLockedAxis(Offset offset) { assert(lastDragOffset != null); final Offset offsetDelta = lastDragOffset! - offset; diff --git a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart index 53cb54846eb7..d4af0def621b 100644 --- a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart @@ -592,7 +592,17 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix } @override - RevealedOffset getOffsetToReveal(RenderObject target, double alignment, { Rect? rect }) { + RevealedOffset getOffsetToReveal( + RenderObject target, + double alignment, { + Rect? rect, + Axis? axis, + }) { + // One dimensional viewport has only one axis, it should match if it has + // been provided. + axis ??= this.axis; + assert(axis == this.axis); + rect ??= target.paintBounds; if (target is! RenderBox) { return RevealedOffset(offset: offset.pixels, rect: rect); diff --git a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart index 282ef4449087..3b3baf1061eb 100644 --- a/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart +++ b/packages/flutter/lib/src/widgets/two_dimensional_viewport.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:flutter/animation.dart'; import 'package:flutter/rendering.dart'; import 'framework.dart'; @@ -497,7 +498,6 @@ class TwoDimensionalViewportParentData extends ParentData with KeepAliveParentD /// /// Subclasses should not override [performLayout], as it handles housekeeping /// on either side of the call to [layoutChildSequence]. -// TODO(Piinks): ensureVisible https://github.com/flutter/flutter/issues/126299 abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderAbstractViewport { /// Initializes fields for subclasses. /// @@ -848,11 +848,7 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA RenderBox? child = _firstChild; while (child != null) { final TwoDimensionalViewportParentData childParentData = parentDataOf(child); - // TODO(Piinks): When ensure visible is supported, remove this isVisible - // condition. - if (childParentData.isVisible) { - visitor(child); - } + visitor(child); child = childParentData._nextSibling; } // Do not visit children in [_keepAliveBucket]. @@ -920,10 +916,274 @@ abstract class RenderTwoDimensionalViewport extends RenderBox implements RenderA } } + @protected + @override + RevealedOffset getOffsetToReveal( + RenderObject target, + double alignment, { + Rect? rect, + Axis? axis, + }) { + // We must know which axis we are revealing for, since RevealedOffset + // refers to only one of two scroll positions. + assert(axis != null); + + final (double offset, AxisDirection axisDirection) = switch (axis!) { + Axis.vertical => (verticalOffset.pixels, verticalAxisDirection), + Axis.horizontal => (horizontalOffset.pixels, horizontalAxisDirection), + }; + + rect ??= target.paintBounds; + // `child` will be the last RenderObject before the viewport when walking + // up from `target`. + RenderObject child = target; + while (child.parent != this) { + child = child.parent!; + } + + assert(child.parent == this); + final RenderBox box = child as RenderBox; + final Rect rectLocal = MatrixUtils.transformRect(target.getTransformTo(child), rect); + + final double targetMainAxisExtent; + double leadingScrollOffset = offset; + // The scroll offset of `rect` within `child`. + switch (axisDirection) { + case AxisDirection.up: + leadingScrollOffset += child.size.height - rectLocal.bottom; + targetMainAxisExtent = rectLocal.height; + case AxisDirection.right: + leadingScrollOffset += rectLocal.left; + targetMainAxisExtent = rectLocal.width; + case AxisDirection.down: + leadingScrollOffset += rectLocal.top; + targetMainAxisExtent = rectLocal.height; + case AxisDirection.left: + leadingScrollOffset += child.size.width - rectLocal.right; + targetMainAxisExtent = rectLocal.width; + } + + // The scroll offset in the viewport to `rect`. + final TwoDimensionalViewportParentData childParentData = parentDataOf(box); + leadingScrollOffset += switch (axisDirection) { + AxisDirection.down => childParentData.paintOffset!.dy, + AxisDirection.up => viewportDimension.height - childParentData.paintOffset!.dy - box.size.height, + AxisDirection.right => childParentData.paintOffset!.dx, + AxisDirection.left => viewportDimension.width - childParentData.paintOffset!.dx - box.size.width, + }; + + // This step assumes the viewport's layout is up-to-date, i.e., if + // the position is changed after the last performLayout, the new scroll + // position will not be accounted for. + final Matrix4 transform = target.getTransformTo(this); + Rect targetRect = MatrixUtils.transformRect(transform, rect); + + final double mainAxisExtent = switch (axisDirectionToAxis(axisDirection)) { + Axis.horizontal => viewportDimension.width, + Axis.vertical => viewportDimension.height, + }; + + final double targetOffset = leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment; + + final double offsetDifference = switch (axisDirectionToAxis(axisDirection)){ + Axis.vertical => verticalOffset.pixels - targetOffset, + Axis.horizontal => horizontalOffset.pixels - targetOffset, + }; + switch (axisDirection) { + case AxisDirection.down: + targetRect = targetRect.translate(0.0, offsetDifference); + case AxisDirection.right: + targetRect = targetRect.translate(offsetDifference, 0.0); + case AxisDirection.up: + targetRect = targetRect.translate(0.0, -offsetDifference); + case AxisDirection.left: + targetRect = targetRect.translate(-offsetDifference, 0.0); + } + + final RevealedOffset revealedOffset = RevealedOffset( + offset: targetOffset, + rect: targetRect, + ); + return revealedOffset; + } + @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); + void showOnScreen({ + RenderObject? descendant, + Rect? rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + // It is possible for one and not both axes to allow for implicit scrolling, + // so handling is split between the options for allowed implicit scrolling. + final bool allowHorizontal = horizontalOffset.allowImplicitScrolling; + final bool allowVertical = verticalOffset.allowImplicitScrolling; + AxisDirection? axisDirection; + switch ((allowHorizontal, allowVertical)) { + case (true, true): + // Both allow implicit scrolling. + break; + case (false, true): + // Only the vertical Axis allows implicit scrolling. + axisDirection = verticalAxisDirection; + case (true, false): + // Only the horizontal Axis allows implicit scrolling. + axisDirection = horizontalAxisDirection; + case (false, false): + // Neither axis allows for implicit scrolling. + return super.showOnScreen( + descendant: descendant, + rect: rect, + duration: duration, + curve: curve, + ); + } + + final Rect? newRect = RenderTwoDimensionalViewport.showInViewport( + descendant: descendant, + viewport: this, + axisDirection: axisDirection, + rect: rect, + duration: duration, + curve: curve, + ); + + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); + } + + /// Make (a portion of) the given `descendant` of the given `viewport` fully + /// visible in one or both dimensions of the `viewport` by manipulating the + /// [ViewportOffset]s. + /// + /// The `axisDirection` determines from which axes the `descendant` will be + /// revealed. When the `axisDirection` is null, both axes will be updated to + /// reveal the descendant. + /// + /// The optional `rect` parameter describes which area of the `descendant` + /// should be shown in the viewport. If `rect` is null, the entire + /// `descendant` will be revealed. The `rect` parameter is interpreted + /// relative to the coordinate system of `descendant`. + /// + /// The returned [Rect] describes the new location of `descendant` or `rect` + /// in the viewport after it has been revealed. See [RevealedOffset.rect] + /// for a full definition of this [Rect]. + /// + /// The parameter `viewport` is required and cannot be null. If `descendant` + /// is null, this is a no-op and `rect` is returned. + /// + /// If both `descendant` and `rect` are null, null is returned because there + /// is nothing to be shown in the viewport. + /// + /// The `duration` parameter can be set to a non-zero value to animate the + /// target object into the viewport with an animation defined by `curve`. + /// + /// See also: + /// + /// * [RenderObject.showOnScreen], overridden by + /// [RenderTwoDimensionalViewport] to delegate to this method. + static Rect? showInViewport({ + RenderObject? descendant, + Rect? rect, + required RenderTwoDimensionalViewport viewport, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + AxisDirection? axisDirection, + }) { + if (descendant == null) { + return rect; + } + + Rect? showVertical(Rect? rect) { + return RenderTwoDimensionalViewport._showInViewportForAxisDirection( + descendant: descendant, + viewport: viewport, + axis: Axis.vertical, + rect: rect, + duration: duration, + curve: curve, + ); + } + + Rect? showHorizontal(Rect? rect) { + return RenderTwoDimensionalViewport._showInViewportForAxisDirection( + descendant: descendant, + viewport: viewport, + axis: Axis.horizontal, + rect: rect, + duration: duration, + curve: curve, + ); + } + + switch (axisDirection) { + case AxisDirection.left: + case AxisDirection.right: + return showHorizontal(rect); + case AxisDirection.up: + case AxisDirection.down: + return showVertical(rect); + case null: + // Update rect after revealing in one axis before revealing in the next. + rect = showHorizontal(rect) ?? rect; + // We only return the final rect after both have been revealed. + rect = showVertical(rect); + if (rect == null) { + // `descendant` is between leading and trailing edge and hence already + // fully shown on screen. + assert(viewport.parent != null); + final Matrix4 transform = descendant.getTransformTo(viewport.parent); + return MatrixUtils.transformRect( + transform, + rect ?? descendant.paintBounds, + ); + } + return rect; + } + } + + static Rect? _showInViewportForAxisDirection({ + required RenderObject descendant, + Rect? rect, + required RenderTwoDimensionalViewport viewport, + required Axis axis, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + final ViewportOffset offset = switch (axis) { + Axis.vertical => viewport.verticalOffset, + Axis.horizontal => viewport.horizontalOffset, + }; + + final RevealedOffset leadingEdgeOffset = viewport.getOffsetToReveal( + descendant, + 0.0, + rect: rect, + axis: axis, + ); + final RevealedOffset trailingEdgeOffset = viewport.getOffsetToReveal( + descendant, + 1.0, + rect: rect, + axis: axis, + ); + final double currentOffset = offset.pixels; + + final RevealedOffset? targetOffset = RevealedOffset.clampOffset( + leadingEdgeOffset: leadingEdgeOffset, + trailingEdgeOffset: trailingEdgeOffset, + currentOffset: currentOffset, + ); + if (targetOffset == null) { + // Already visible in this axis. + return null; + } + + offset.moveTo(targetOffset.offset, duration: duration, curve: curve); + return targetOffset.rect; } /// Should be used by subclasses to invalidate any cached metrics for the diff --git a/packages/flutter/test/widgets/ensure_visible_test.dart b/packages/flutter/test/widgets/ensure_visible_test.dart index 329388ee11a8..97898ca632f7 100644 --- a/packages/flutter/test/widgets/ensure_visible_test.dart +++ b/packages/flutter/test/widgets/ensure_visible_test.dart @@ -9,6 +9,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; +import 'two_dimensional_utils.dart'; + Finder findKey(int i) => find.byKey(ValueKey(i), skipOffstage: false); Widget buildSingleChildScrollView(Axis scrollDirection, { bool reverse = false }) { @@ -1051,4 +1053,279 @@ void main() { expect(tester.getTopLeft(findKey(-3)).dy, equals(100.0)); }); }); + + group('TwoDimensionalViewport ensureVisible', () { + Finder findKey(ChildVicinity vicinity) { + return find.byKey(ValueKey(vicinity)); + } + + BuildContext findContext(WidgetTester tester, ChildVicinity vicinity) { + return tester.element(findKey(vicinity)); + } + + testWidgets('Axis.vertical', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 0)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(0.0), + ); + // (0, 3) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(600.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 3)), + ); + await tester.pump(); + // Now in view at top edge of viewport + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(0.0), + ); + + // If already visible, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 3)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(0.0), + ); + }); + + testWidgets('Axis.horizontal', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 1, yIndex: 0)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 1, yIndex: 0))).dx, + equals(0.0), + ); + // (5, 0) is now in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(800.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 0)), + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); + await tester.pump(); + // Now in view at trailing edge of viewport + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(600.0), + ); + + // If already in position, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 0)), + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(600.0), + ); + }); + + testWidgets('both axes', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 1, yIndex: 1)), + ); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 1, yIndex: 1))), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + // (5, 4) is in the cache extent, and will be brought into view next + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(800.0, 600.0, 1000.0, 800.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 4)), + alignment: 1.0, // Same as ScrollAlignmentPolicy.keepVisibleAtEnd + ); + await tester.pump(); + // Now in view at bottom trailing corner of viewport + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + + // If already visible, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 4)), + alignment: 1.0, + ); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + }); + + testWidgets('Axis.vertical reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + verticalDetails: const ScrollableDetails.vertical(reverse: true), + useCacheExtent: true, + )); + + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(400.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 0)), + ); + await tester.pump(); + // Already visible so no change. + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(400.0), + ); + // (0, 3) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(-200.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 3)), + ); + await tester.pump(); + // Now in view at bottom edge of viewport since we are reversed + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(400.0), + ); + + // If already visible, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 3)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(400.0), + ); + }); + + testWidgets('Axis.horizontal reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + horizontalDetails: const ScrollableDetails.horizontal(reverse: true), + useCacheExtent: true, + )); + + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dx, + equals(600.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 0, yIndex: 0)), + ); + await tester.pump(); + // Already visible so no change. + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dx, + equals(600.0), + ); + // (4, 0) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(-200.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 4, yIndex: 0)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(200.0), + ); + + // If already visible, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 4, yIndex: 0)), + ); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(200.0), + ); + }); + + testWidgets('both axes reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + verticalDetails: const ScrollableDetails.vertical(reverse: true), + horizontalDetails: const ScrollableDetails.horizontal(reverse: true), + useCacheExtent: true, + )); + + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 1, yIndex: 1)), + ); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 1, yIndex: 1))), + const Rect.fromLTRB(600.0, 400.0, 800.0, 600.0), + ); + // (5, 4) is in the cache extent, and will be brought into view next + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(-200.0, -200.0, 0.0, 0.0), + ); + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 4)), + alignment: 1.0, // Same as ScrollAlignmentPolicy.keepVisibleAtEnd + ); + await tester.pump(); + // Now in view at trailing corner of viewport + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + + // If already visible, no change + Scrollable.ensureVisible(findContext( + tester, + const ChildVicinity(xIndex: 5, yIndex: 4)), + alignment: 1.0, + ); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(0.0, 0.0, 200.0, 200.0), + ); + }); + }); } diff --git a/packages/flutter/test/widgets/two_dimensional_utils.dart b/packages/flutter/test/widgets/two_dimensional_utils.dart index d2cbf3b8883a..a518f52170a1 100644 --- a/packages/flutter/test/widgets/two_dimensional_utils.dart +++ b/packages/flutter/test/widgets/two_dimensional_utils.dart @@ -17,6 +17,7 @@ final TwoDimensionalChildBuilderDelegate builderDelegate = TwoDimensionalChildBu maxYIndex: 5, builder: (BuildContext context, ChildVicinity vicinity) { return Container( + key: ValueKey(vicinity), color: vicinity.xIndex.isEven && vicinity.yIndex.isEven ? Colors.amber[100] : (vicinity.xIndex.isOdd && vicinity.yIndex.isOdd diff --git a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart index 8063ea0d1a69..1d10521d9139 100644 --- a/packages/flutter/test/widgets/two_dimensional_viewport_test.dart +++ b/packages/flutter/test/widgets/two_dimensional_viewport_test.dart @@ -2365,6 +2365,275 @@ void main() { ), ); }, variant: TargetPlatformVariant.all()); + + group('TwoDimensionalViewport showOnScreen & showInViewport', () { + Finder findKey(ChildVicinity vicinity) { + return find.byKey(ValueKey(vicinity)); + } + + testWidgets('Axis.vertical', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + // Child visible at origin + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(0.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(0.0), + ); + // (0, 3) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(600.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 3)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Now in view + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(400.0), + ); + + // If already visible, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 3)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(400.0), + ); + }); + + testWidgets('Axis.horizontal', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 1, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 1, yIndex: 0))).dx, + equals(200.0), // No change since already fully visible + ); + // (5, 0) is now in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(1000.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Now in view + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(600.0), + ); + + // If already in position, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 5, yIndex: 0))).dx, + equals(600.0), + ); + }); + + testWidgets('both axes', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest(useCacheExtent: true)); + + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 1, yIndex: 1)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 1, yIndex: 1))), + const Rect.fromLTRB(200.0, 200.0, 400.0, 400.0), + ); + // (5, 4) is in the cache extent, and will be brought into view next + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(1000.0, 800.0, 1200.0, 1000.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 4)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Now in view + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(600.0, 200.0, 800.0, 400.0), + ); + + // If already visible, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 4)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(600.0, 200.0, 800.0, 400.0), + ); + }); + + testWidgets('Axis.vertical reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + verticalDetails: const ScrollableDetails.vertical(reverse: true), + useCacheExtent: true, + )); + + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(400.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Already visible so no change. + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dy, + equals(400.0), + ); + // (0, 3) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(-200.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 3)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Now in view + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(0.0), + ); + + // If already visible, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 3)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 3))).dy, + equals(0.0), + ); + }); + + testWidgets('Axis.horizontal reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + horizontalDetails: const ScrollableDetails.horizontal(reverse: true), + useCacheExtent: true, + )); + + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dx, + equals(600.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 0, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Already visible so no change. + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 0, yIndex: 0))).dx, + equals(600.0), + ); + // (4, 0) is in the cache extent, and will be brought into view next + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(-200.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 4, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(0.0), + ); + + // If already visible, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 4, yIndex: 0)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getTopLeft(findKey(const ChildVicinity(xIndex: 4, yIndex: 0))).dx, + equals(0.0), + ); + }); + + testWidgets('both axes reverse', (WidgetTester tester) async { + await tester.pumpWidget(simpleBuilderTest( + verticalDetails: const ScrollableDetails.vertical(reverse: true), + horizontalDetails: const ScrollableDetails.horizontal(reverse: true), + useCacheExtent: true, + )); + + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 1, yIndex: 1)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 1, yIndex: 1))), + const Rect.fromLTRB(400.0, 200.0, 600.0, 400.0), + ); + // (5, 4) is in the cache extent, and will be brought into view next + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(-400.0, -400.0, -200.0, -200.0), + ); + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 4)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + // Now in view + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(0.0, 200.0, 200.0, 400.0), + ); + + // If already visible, no change + tester.renderObject(find.byKey( + const ValueKey(ChildVicinity(xIndex: 5, yIndex: 4)), + skipOffstage: false, + )).showOnScreen(); + await tester.pump(); + expect( + tester.getRect(findKey(const ChildVicinity(xIndex: 5, yIndex: 4))), + const Rect.fromLTRB(0.0, 200.0, 200.0, 400.0), + ); + }); + }); }); }