Unverified Commit 80fb7bd2 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Support ensureVisible/showOnScreen/showInViewport for 2D Scrolling (#135182)

parent 47f12cae
......@@ -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`.
......
......@@ -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<ParentDataClass extends ContainerParentDataMix
}
@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);
// Steps to convert `rect` (from a RenderBox coordinate system) to its
// scroll offset within this viewport (not in the exact order):
//
......@@ -1164,44 +1236,12 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
final RevealedOffset leadingEdgeOffset = viewport.getOffsetToReveal(descendant, 0.0, rect: rect);
final RevealedOffset trailingEdgeOffset = viewport.getOffsetToReveal(descendant, 1.0, rect: rect);
final double currentOffset = offset.pixels;
// 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 RevealedOffset targetOffset;
if (leadingEdgeOffset.offset < trailingEdgeOffset.offset) {
// `descendant` is too big to be visible on screen in its entirety. Let's
// align it with the edge that requires the least amount of scrolling.
final double leadingEdgeDiff = (offset.pixels - leadingEdgeOffset.offset).abs();
final double trailingEdgeDiff = (offset.pixels - trailingEdgeOffset.offset).abs();
targetOffset = leadingEdgeDiff < trailingEdgeDiff ? leadingEdgeOffset : trailingEdgeOffset;
} else if (currentOffset > 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<ParentDataClass extends ContainerParentDataMix
return MatrixUtils.transformRect(transform, rect ?? descendant.paintBounds);
}
offset.moveTo(targetOffset.offset, duration: duration, curve: curve);
return targetOffset.rect;
}
......
......@@ -810,14 +810,32 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
double target;
switch (_applyAxisDirectionToAlignmentPolicy(alignmentPolicy)) {
case ScrollPositionAlignmentPolicy.explicit:
target = clampDouble(viewport.getOffsetToReveal(object, alignment, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
target = viewport.getOffsetToReveal(
object,
alignment,
rect: targetRect,
axis: axis,
).offset;
target = clampDouble(target, minScrollExtent, maxScrollExtent);
case ScrollPositionAlignmentPolicy.keepVisibleAtEnd:
target = clampDouble(viewport.getOffsetToReveal(object, 1.0, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
target = viewport.getOffsetToReveal(
object,
1.0, // Aligns to end
rect: targetRect,
axis: axis,
).offset;
target = clampDouble(target, minScrollExtent, maxScrollExtent);
if (target < pixels) {
target = pixels;
}
case ScrollPositionAlignmentPolicy.keepVisibleAtStart:
target = clampDouble(viewport.getOffsetToReveal(object, 0.0, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
target = viewport.getOffsetToReveal(
object,
0.0, // Aligns to start
rect: targetRect,
axis: axis,
).offset;
target = clampDouble(target, minScrollExtent, maxScrollExtent);
if (target > pixels) {
target = pixels;
}
......
......@@ -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<Future<void>>, 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<void> 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<Future<void>> 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<Scrollable> 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<void> ensureVisibleFuture = position.ensureVisible(
object,
alignment: alignment,
duration: duration,
curve: curve,
alignmentPolicy: alignmentPolicy,
targetRenderObject: targetRenderObject,
);
return (<Future<void>>[ 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 (<Future<void>>[], 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<Future<void>> newFutures = <Future<void>>[];
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;
......
......@@ -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);
......
......@@ -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);
}
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 }) {
// 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);
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
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
......
......@@ -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<int>(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<ChildVicinity>(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),
);
});
});
}
......@@ -17,6 +17,7 @@ final TwoDimensionalChildBuilderDelegate builderDelegate = TwoDimensionalChildBu
maxYIndex: 5,
builder: (BuildContext context, ChildVicinity vicinity) {
return Container(
key: ValueKey<ChildVicinity>(vicinity),
color: vicinity.xIndex.isEven && vicinity.yIndex.isEven
? Colors.amber[100]
: (vicinity.xIndex.isOdd && vicinity.yIndex.isOdd
......
......@@ -2365,6 +2365,275 @@ void main() {
),
);
}, variant: TargetPlatformVariant.all());
group('TwoDimensionalViewport showOnScreen & showInViewport', () {
Finder findKey(ChildVicinity vicinity) {
return find.byKey(ValueKey<ChildVicinity>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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),
);
});
});
});
}
......
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