Skip to content

Commit

Permalink
Support ensureVisible/showOnScreen/showInViewport for 2D Scrolling (#…
Browse files Browse the repository at this point in the history
…135182)
  • Loading branch information
Piinks authored Sep 27, 2023
1 parent 47f12ca commit 80fb7bd
Show file tree
Hide file tree
Showing 9 changed files with 1,026 additions and 58 deletions.
11 changes: 9 additions & 2 deletions packages/flutter/lib/src/rendering/list_wheel_viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
121 changes: 80 additions & 41 deletions packages/flutter/lib/src/rendering/viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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)';
Expand Down Expand Up @@ -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):
//
Expand Down Expand Up @@ -1164,52 +1236,19 @@ 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);
final Matrix4 transform = descendant.getTransformTo(viewport.parent);
return MatrixUtils.transformRect(transform, rect ?? descendant.paintBounds);
}


offset.moveTo(targetOffset.offset, duration: duration, curve: curve);
return targetOffset.rect;
}
Expand Down
24 changes: 21 additions & 3 deletions packages/flutter/lib/src/widgets/scroll_position.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
91 changes: 89 additions & 2 deletions packages/flutter/lib/src/widgets/scrollable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion packages/flutter/lib/src/widgets/single_child_scroll_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 80fb7bd

Please sign in to comment.