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