diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart index f050dba0df..e67195e182 100644 --- a/packages/flutter/lib/src/widgets/page_view.dart +++ b/packages/flutter/lib/src/widgets/page_view.dart @@ -331,6 +331,29 @@ class _PagePosition extends ScrollPositionWithSingleContext implements PageMetri final int initialPage; double _pageToUseOnStartup; + @override + Future ensureVisible( + RenderObject object, { + double alignment = 0.0, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, + }) { + // Since the _PagePosition is intended to cover the available space within + // its viewport, stop trying to move the target render object to the center + // - otherwise, could end up changing which page is visible and moving the + // targetRenderObject out of the viewport. + return super.ensureVisible( + object, + alignment: alignment, + duration: duration, + curve: curve, + alignmentPolicy: alignmentPolicy, + targetRenderObject: null, + ); + } + @override double get viewportFraction => _viewportFraction; double _viewportFraction; diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 29c78352e7..45d1606ee8 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -647,6 +647,12 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// Animates the position such that the given object is as visible as possible /// by just scrolling this position. /// + /// The optional `targetRenderObject` parameter is used to determine which area + /// of that object should be as visible as possible. If `targetRenderObject` + /// is null, the entire [RenderObject] (as defined by its + /// [RenderObject.paintBounds]) will be as visible as possible. If + /// `targetRenderObject` is provided, it must be a descendant of the object. + /// /// See also: /// /// * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is @@ -657,25 +663,34 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { Duration duration = Duration.zero, Curve curve = Curves.ease, ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, + RenderObject? targetRenderObject, }) { assert(alignmentPolicy != null); assert(object.attached); final RenderAbstractViewport viewport = RenderAbstractViewport.of(object)!; assert(viewport != null); + Rect? targetRect; + if (targetRenderObject != null && targetRenderObject != object) { + targetRect = MatrixUtils.transformRect( + targetRenderObject.getTransformTo(object), + object.paintBounds.intersect(targetRenderObject.paintBounds) + ); + } + double target; switch (alignmentPolicy) { case ScrollPositionAlignmentPolicy.explicit: - target = viewport.getOffsetToReveal(object, alignment).offset.clamp(minScrollExtent, maxScrollExtent); + target = viewport.getOffsetToReveal(object, alignment, rect: targetRect).offset.clamp(minScrollExtent, maxScrollExtent); break; case ScrollPositionAlignmentPolicy.keepVisibleAtEnd: - target = viewport.getOffsetToReveal(object, 1.0).offset.clamp(minScrollExtent, maxScrollExtent); + target = viewport.getOffsetToReveal(object, 1.0, rect: targetRect).offset.clamp(minScrollExtent, maxScrollExtent); if (target < pixels) { target = pixels; } break; case ScrollPositionAlignmentPolicy.keepVisibleAtStart: - target = viewport.getOffsetToReveal(object, 0.0).offset.clamp(minScrollExtent, maxScrollExtent); + target = viewport.getOffsetToReveal(object, 0.0, rect: targetRect).offset.clamp(minScrollExtent, maxScrollExtent); if (target > pixels) { target = pixels; } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 25feab619e..d6ffbc349e 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -307,6 +307,13 @@ class Scrollable extends StatefulWidget { }) { final List> futures = >[]; + // The `targetRenderObject` is used to record the first target renderObject. + // If there are multiple scrollable widgets nested, we should let + // the `targetRenderObject` as visible as possible to improve the user experience. + // Otherwise, let the outer renderObject as visible as possible maybe cause + // the `targetRenderObject` invisible. + // Also see https://github.com/flutter/flutter/issues/65100 + RenderObject? targetRenderObject; ScrollableState? scrollable = Scrollable.of(context); while (scrollable != null) { futures.add(scrollable.position.ensureVisible( @@ -315,7 +322,10 @@ class Scrollable extends StatefulWidget { duration: duration, curve: curve, alignmentPolicy: alignmentPolicy, + targetRenderObject: targetRenderObject, )); + + targetRenderObject = targetRenderObject ?? context.findRenderObject(); context = scrollable.context; scrollable = Scrollable.of(context); } diff --git a/packages/flutter/test/widgets/ensure_visible_test.dart b/packages/flutter/test/widgets/ensure_visible_test.dart index 442de3969a..1a17b01612 100644 --- a/packages/flutter/test/widgets/ensure_visible_test.dart +++ b/packages/flutter/test/widgets/ensure_visible_test.dart @@ -224,6 +224,83 @@ void main() { await tester.pump(); expect(tester.getTopLeft(findKey(0)).dy, moreOrLessEquals(500.0, epsilon: 0.1)); }); + + testWidgets('Nested SingleChildScrollView ensureVisible behavior test', (WidgetTester tester) async { + // Regressing test for https://github.com/flutter/flutter/issues/65100 + Finder findKey(String coordinate) => find.byKey(ValueKey(coordinate)); + BuildContext findContext(String coordinate) => tester.element(findKey(coordinate)); + final List rows = List.generate( + 7, + (int y) => Row( + children: List.generate( + 7, + (int x) => Container(key: ValueKey('$x, $y'), width: 200.0, height: 200.0,), + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 600.0, + height: 400.0, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: rows, + ), + ), + ), + ), + ), + ), + ); + + // Items: 7 * 7 Container(width: 200.0, height: 200.0) + // viewport: Size(width: 600.0, height: 400.0) + // + // 0 600 + // +----------------------+ + // |0,0 |1,0 |2,0 | + // | | | | + // +----------------------+ + // |0,1 |1,1 |2,1 | + // | | | | + // 400 +----------------------+ + + Scrollable.ensureVisible(findContext('0, 0')); + await tester.pump(); + expect(tester.getTopLeft(findKey('0, 0')), const Offset(100.0, 100.0)); + + Scrollable.ensureVisible(findContext('3, 0')); + await tester.pump(); + expect(tester.getTopLeft(findKey('3, 0')), const Offset(100.0, 100.0)); + + Scrollable.ensureVisible(findContext('3, 0'), alignment: 0.5); + await tester.pump(); + expect(tester.getTopLeft(findKey('3, 0')), const Offset(300.0, 100.0)); + + Scrollable.ensureVisible(findContext('6, 0')); + await tester.pump(); + expect(tester.getTopLeft(findKey('6, 0')), const Offset(500.0, 100.0)); + + Scrollable.ensureVisible(findContext('0, 2')); + await tester.pump(); + expect(tester.getTopLeft(findKey('0, 2')), const Offset(100.0, 100.0)); + + Scrollable.ensureVisible(findContext('3, 2')); + await tester.pump(); + expect(tester.getTopLeft(findKey('3, 2')), const Offset(100.0, 100.0)); + + // It should be at the center of the screen. + Scrollable.ensureVisible(findContext('3, 2'), alignment: 0.5); + await tester.pump(); + expect(tester.getTopLeft(findKey('3, 2')), const Offset(300.0, 200.0)); + }); }); group('ListView', () {