From 3b9b5acefc740d095735591dc5f3d3e18a79ef1b Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Thu, 7 Jun 2018 10:07:52 -0700 Subject: [PATCH] showOnScreen Improvements (#18252) --- .../src/rendering/list_wheel_viewport.dart | 56 +- .../flutter/lib/src/rendering/object.dart | 35 +- .../flutter/lib/src/rendering/viewport.dart | 237 ++++++-- .../lib/src/rendering/viewport_offset.dart | 23 +- .../lib/src/widgets/scroll_position.dart | 3 +- .../src/widgets/single_child_scroll_view.dart | 46 +- .../flutter/test/rendering/viewport_test.dart | 540 ++++++++++++++++++ .../widgets/list_wheel_scroll_view_test.dart | 123 ++++ .../single_child_scroll_view_test.dart | 484 ++++++++++++++++ 9 files changed, 1462 insertions(+), 85 deletions(-) create mode 100644 packages/flutter/test/rendering/viewport_test.dart diff --git a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart index 4b9fff6c7f..8622bae4d0 100644 --- a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart +++ b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; @@ -614,27 +615,50 @@ class RenderListWheelViewport } @override - double getOffsetToReveal(RenderObject target, double alignment) { - final ListWheelParentData parentData = target.parentData; - final double centerPosition = parentData.offset.dy; + RevealedOffset getOffsetToReveal(RenderObject target, double alignment, {Rect rect}) { + // `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. - if (alignment < 0.5) { - return centerPosition + _topScrollMarginExtent * alignment * 2.0; - } else if (alignment > 0.5) { - return centerPosition - _topScrollMarginExtent * (alignment - 0.5) * 2.0; - } else { - return centerPosition; - } + 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; + + final ListWheelParentData parentData = child.parentData; + final double targetOffset = parentData.offset.dy; // the so-called "centerPosition" + + final Matrix4 transform = target.getTransformTo(this); + final Rect bounds = MatrixUtils.transformRect(transform, rect); + final Rect targetRect = bounds.translate(0.0, (size.height - itemExtent) / 2); + + return new RevealedOffset(offset: targetOffset, rect: targetRect); } @override - void showOnScreen([RenderObject child]) { - if (child != null) { - // Shows the child in the selected/center position. - offset.jumpTo(getOffsetToReveal(child, 0.5)); + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + if (descendant != null) { + // Shows the descendant in the selected/center position. + final RevealedOffset revealedOffset = getOffsetToReveal(descendant, 0.5, rect: rect); + if (duration == Duration.zero) { + offset.jumpTo(revealedOffset.offset); + } else { + offset.animateTo(revealedOffset.offset, duration: duration, curve: curve); + } + rect = revealedOffset.rect; } - // Make sure the viewport itself is on screen. - super.showOnScreen(); + super.showOnScreen( + rect: rect, + duration: duration, + curve: curve, + ); } } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 66b5759aa8..85b3d9cecf 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -5,6 +5,7 @@ import 'dart:developer'; import 'dart:ui' as ui show PictureRecorder; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/painting.dart'; @@ -2036,6 +2037,9 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im /// An estimate of the bounds within which this render object will paint. /// Useful for debugging flags such as [debugPaintLayerBordersEnabled]. + /// + /// These are also the bounds used by [showOnScreen] to make a [RenderObject] + /// visible on screen. Rect get paintBounds; /// Override this method to paint debugging information. @@ -2570,14 +2574,35 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im @override List debugDescribeChildren() => []; - /// Attempt to make this or a descendant RenderObject visible on screen. + /// Attempt to make (a portion of) this or a descendant [RenderObject] visible + /// on screen. /// - /// If [child] is provided, that [RenderObject] is made visible. If [child] is - /// omitted, this [RenderObject] is made visible. - void showOnScreen([RenderObject child]) { + /// If `descendant` is provided, that [RenderObject] is made visible. If + /// `descendant` is omitted, this [RenderObject] is made visible. + /// + /// The optional `rect` parameter describes which area of that [RenderObject] + /// should be shown on screen. If `rect` is null, the entire + /// [RenderObject] (as defined by its [paintBounds]) will be revealed. The + /// `rect` parameter is interpreted relative to the coordinate system of + /// `descendant` if that argument is provided and relative to this + /// [RenderObject] otherwise. + /// + /// The `duration` parameter can be set to a non-zero value to bring the + /// target object on screen in an animation defined by `curve`. + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { if (parent is RenderObject) { final RenderObject renderParent = parent; - renderParent.showOnScreen(child ?? this); + renderParent.showOnScreen( + descendant: descendant ?? this, + rect: rect, + duration: duration, + curve: curve, + ); } } } diff --git a/packages/flutter/lib/src/rendering/viewport.dart b/packages/flutter/lib/src/rendering/viewport.dart index 56802b11ed..82116e82fc 100644 --- a/packages/flutter/lib/src/rendering/viewport.dart +++ b/packages/flutter/lib/src/rendering/viewport.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; @@ -40,7 +41,14 @@ abstract class RenderAbstractViewport extends RenderObject { return null; } - /// Returns the offset that would be needed to reveal the target render object. + /// Returns the offset that would be needed to reveal the `target` + /// [RenderObject]. + /// + /// The optional `rect` parameter describes which area of that `target` object + /// should be revealed in the viewport. If `rect` is null, the entire + /// `target` [RenderObject] (as defined by its [RenderObject.paintBounds]) + /// will be revealed. If `rect` is provided it has to be given in the + /// coordinate system of the `target` object. /// /// The `alignment` argument describes where the target should be positioned /// after applying the returned offset. If `alignment` is 0.0, the child must @@ -52,7 +60,15 @@ abstract class RenderAbstractViewport extends RenderObject { /// The target might not be a direct child of this viewport but it must be a /// descendant of the viewport and there must not be any other /// [RenderAbstractViewport] objects between the target and this object. - double getOffsetToReveal(RenderObject target, double alignment); + /// + /// This method assumes that the content of the viewport moves linearly, i.e. + /// when the offset of the viewport is changed by x then `target` also moves + /// by x within the viewport. + /// + /// See also: + /// + /// * [RevealedOffset], which describes the return value of this method. + RevealedOffset getOffsetToReveal(RenderObject target, double alignment, {Rect rect}); /// The default value for the cache extent of the viewport. /// @@ -63,6 +79,59 @@ abstract class RenderAbstractViewport extends RenderObject { static const double defaultCacheExtent = 250.0; } +/// Return value for [RenderAbstractViewport.getOffsetToReveal]. +/// +/// It indicates the [offset] required to reveal an element in a viewport and +/// the [rect] position said element would have in the viewport at that +/// [offset]. +class RevealedOffset { + + /// Instantiates a return value for [RenderAbstractViewport.getOffsetToReveal]. + const RevealedOffset({ + @required this.offset, + @required this.rect, + }) : assert(offset != null), assert(rect != null); + + /// Offset for the viewport to reveal a specific element in the viewport. + /// + /// See also: + /// + /// * [RenderAbstractViewport.getOffsetToReveal], which calculates this + /// value for a specific element. + final double offset; + + /// The [Rect] in the outer coordinate system of the viewport at which the + /// to-be-revealed element would be located if the viewport's offset is set + /// to [offset]. + /// + /// A viewport usually has two coordinate systems and works as an adapter + /// between the two: + /// + /// The inner coordinate system has its origin at the top left corner of the + /// content that moves inside the viewport. The origin of this coordinate + /// system usually moves around relative to the leading edge of the viewport + /// when the viewport offset changes. + /// + /// The outer coordinate system has its origin at the top left corner of the + /// visible part of the viewport. This origin stays at the same position + /// regardless of the current viewport offset. + /// + /// In other words: [rect] describes where the revealed element would be + /// located relative to the top left corner of the visible part of the + /// viewport if the viewport's offset is set to [offset]. + /// + /// See also: + /// + /// * [RenderAbstractViewport.getOffsetToReveal], which calculates this + /// value for a specific element. + final Rect rect; + + @override + String toString() { + return '$runtimeType(offset: $offset, rect: $rect)'; + } +} + /// A base class for render objects that are bigger on the inside. /// /// This render object provides the shared code for render objects that host @@ -512,14 +581,16 @@ abstract class RenderViewportBase get childrenInHitTestOrder; @override - void showOnScreen([RenderObject child]) { - RenderViewportBase.showInViewport(child: child, viewport: this, offset: offset); - // Make sure the viewport itself is on screen. - super.showOnScreen(); + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + final Rect newRect = RenderViewportBase.showInViewport( + descendant: descendant, + viewport: this, + offset: offset, + rect: rect, + duration: duration, + curve: curve, + ); + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); } - /// Make the given `child` of the given `viewport` fully visible in the - /// `viewport` by manipulating the provided [ViewportOffset] `offset`. + /// Make (a portion of) the given `descendant` of the given `viewport` fully + /// visible in the `viewport` by manipulating the provided [ViewportOffset] + /// `offset`. + /// + /// 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 parameters `viewport` and `offset` are required and cannot be null. - /// If `child` is null this is a no-op. - static void showInViewport({ - RenderObject child, + /// If `descendant` is null, this is a no-op and `rect` is returned. + /// + /// If both `decedent` 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`. + static Rect showInViewport({ + RenderObject descendant, + Rect rect, @required RenderAbstractViewport viewport, @required ViewportOffset offset, + Duration duration = Duration.zero, + Curve curve = Curves.ease, }) { assert(viewport != null); assert(offset != null); - if (child == null) { - return; + if (descendant == null) { + return rect; } - final double leadingEdgeOffset = viewport.getOffsetToReveal(child, 0.0); - final double trailingEdgeOffset = viewport.getOffsetToReveal(child, 1.0); + 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 `child` at | | | _ - // trailing edge |_ | xxxxxxx | | viewport position - // | | | with `child` at - // | | _| leading edge - // | | - // 800 +---------+ + // 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. @@ -829,19 +951,36 @@ abstract class RenderViewportBase= trailingEdgeOffset); - - if (currentOffset > leadingEdgeOffset) { - // `child` currently starts above the leading edge and can be shown fully - // on screen by scrolling down (which means: moving viewport up). - offset.jumpTo(leadingEdgeOffset); - } else if (currentOffset < trailingEdgeOffset ) { - // `child currently ends below the trailing edge and can be shown fully - // on screen by scrolling up (which means: moving viewport down) - offset.jumpTo(trailingEdgeOffset); + 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 { + // `descendant` is between leading and trailing edge and hence already + // fully shown on screen. No action necessary. + final Matrix4 transform = descendant.getTransformTo(viewport.parent); + return MatrixUtils.transformRect(transform, rect ?? descendant.paintBounds); } - // else: `child` is between leading and trailing edge and hence already - // fully shown on screen. No action necessary. + + assert(targetOffset != null); + + if (duration == Duration.zero) { + offset.jumpTo(targetOffset.offset); + } else { + offset.animateTo(targetOffset.offset, duration: duration, curve: curve); + } + return targetOffset.rect; } } diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index fc907be6db..ff21b6d3e9 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; /// The direction of a scroll, relative to the positive scroll offset axis given @@ -157,7 +160,7 @@ abstract class ViewportOffset extends ChangeNotifier { /// [jumpTo] applies the change immediately and notifies its listeners. void correctBy(double correction); - /// Jumps the scroll position from its current value to the given value, + /// Jumps [pixels] from its current value to the given value, /// without animation, and without checking if the new value is in range. /// /// See also: @@ -166,6 +169,18 @@ abstract class ViewportOffset extends ChangeNotifier { /// and that defers the notification of its listeners until after layout. void jumpTo(double pixels); + /// Animates [pixels] from its current value to the given value. + /// + /// The returned [Future] will complete when the animation ends, whether it + /// completed successfully or whether it was interrupted prematurely. + /// + /// The duration must not be zero. To jump to a particular value without an + /// animation, use [jumpTo]. + Future animateTo(double to, { + @required Duration duration, + @required Curve curve, + }); + /// The direction in which the user is trying to change [pixels], relative to /// the viewport's [RenderViewport.axisDirection]. /// @@ -227,6 +242,12 @@ class _FixedViewportOffset extends ViewportOffset { // Do nothing, viewport is fixed. } + @override + Future animateTo(double to, { + @required Duration duration, + @required Curve curve, + }) async => null; + @override ScrollDirection get userScrollDirection => ScrollDirection.idle; } diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index e6a2251c45..8dc2e8ae88 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -497,7 +497,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); assert(viewport != null); - final double target = viewport.getOffsetToReveal(object, alignment).clamp(minScrollExtent, maxScrollExtent); + final double target = viewport.getOffsetToReveal(object, alignment).offset.clamp(minScrollExtent, maxScrollExtent); if (target == pixels) return new Future.value(); @@ -544,6 +544,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { /// animation, use [jumpTo]. /// /// The animation is typically handled by an [DrivenScrollActivity]. + @override Future animateTo(double to, { @required Duration duration, @required Curve curve, 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 cd32b4cfbf..6febf92dc7 100644 --- a/packages/flutter/lib/src/widgets/single_child_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/single_child_scroll_view.dart @@ -484,17 +484,19 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent); } - Offset get _paintOffset { + Offset get _paintOffset => _paintOffsetForPosition(offset.pixels); + + Offset _paintOffsetForPosition(double position) { assert(axisDirection != null); switch (axisDirection) { case AxisDirection.up: - return new Offset(0.0, _offset.pixels - child.size.height + size.height); + return new Offset(0.0, position - child.size.height + size.height); case AxisDirection.down: - return new Offset(0.0, -_offset.pixels); + return new Offset(0.0, -position); case AxisDirection.left: - return new Offset(_offset.pixels - child.size.width + size.width, 0.0); + return new Offset(position - child.size.width + size.width, 0.0); case AxisDirection.right: - return new Offset(-_offset.pixels, 0.0); + return new Offset(-position, 0.0); } return null; } @@ -544,13 +546,14 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix } @override - double getOffsetToReveal(RenderObject target, double alignment) { + RevealedOffset getOffsetToReveal(RenderObject target, double alignment, {Rect rect}) { + rect ??= target.paintBounds; if (target is! RenderBox) - return offset.pixels; + return new RevealedOffset(offset: offset.pixels, rect: rect); final RenderBox targetBox = target; final Matrix4 transform = targetBox.getTransformTo(this); - final Rect bounds = MatrixUtils.transformRect(transform, targetBox.paintBounds); + final Rect bounds = MatrixUtils.transformRect(transform, rect); final Size contentSize = child.size; double leadingScrollOffset; @@ -581,14 +584,31 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix break; } - return leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment; + final double targetOffset = leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment; + final Rect targetRect = bounds.shift(_paintOffsetForPosition(targetOffset)); + return new RevealedOffset(offset: targetOffset, rect: targetRect); } @override - void showOnScreen([RenderObject child]) { - RenderViewportBase.showInViewport(child: child, viewport: this, offset: offset); - // Make sure the viewport itself is on screen. - super.showOnScreen(); + void showOnScreen({ + RenderObject descendant, + Rect rect, + Duration duration = Duration.zero, + Curve curve = Curves.ease, + }) { + final Rect newRect = RenderViewportBase.showInViewport( + descendant: descendant, + viewport: this, + offset: offset, + rect: rect, + duration: duration, + curve: curve, + ); + super.showOnScreen( + rect: newRect, + duration: duration, + curve: curve, + ); } @override diff --git a/packages/flutter/test/rendering/viewport_test.dart b/packages/flutter/test/rendering/viewport_test.dart new file mode 100644 index 0000000000..a2dfa7c79c --- /dev/null +++ b/packages/flutter/test/rendering/viewport_test.dart @@ -0,0 +1,540 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + testWidgets('Viewport getOffsetToReveal - down', (WidgetTester tester) async { + List children; + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new ListView( + controller: new ScrollController(initialScrollOffset: 300.0), + children: children = new List.generate(20, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 540.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 350.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); + }); + + testWidgets('Viewport getOffsetToReveal - right', (WidgetTester tester) async { + List children; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 300.0, + width: 200.0, + child: new ListView( + scrollDirection: Axis.horizontal, + controller: new ScrollController(initialScrollOffset: 300.0), + children: children = new List.generate(20, (int i) { + return new Container( + height: 300.0, + width: 100.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 540.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 350.0); + expect(revealed.rect, new Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); + }); + + testWidgets('Viewport getOffsetToReveal - up', (WidgetTester tester) async { + List children; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new ListView( + controller: new ScrollController(initialScrollOffset: 300.0), + reverse: true, + children: children = new List.generate(20, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 550.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 360.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); + }); + + testWidgets('Viewport getOffsetToReveal - left', (WidgetTester tester) async { + List children; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 300.0, + width: 200.0, + child: new ListView( + scrollDirection: Axis.horizontal, + reverse: true, + controller: new ScrollController(initialScrollOffset: 300.0), + children: children = new List.generate(20, (int i) { + return new Container( + height: 300.0, + width: 100.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5], skipOffstage: false)); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 550.0); + expect(revealed.rect, new Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 360.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); + }); + + testWidgets('Nested Viewports showOnScreen', (WidgetTester tester) async { + final List> children = new List>(10); + final List controllersX = new List.generate(10, (int i) => new ScrollController(initialScrollOffset: 400.0)); + final ScrollController controllerY = new ScrollController(initialScrollOffset: 400.0); + + /// Builds a gird: + /// + /// <- x -> + /// 0 1 2 3 4 5 6 7 8 9 + /// 0 c c c c c c c c c c + /// 1 c c c c c c c c c c + /// 2 c c c c c c c c c c + /// 3 c c c c c c c c c c y + /// 4 c c c c v v c c c c + /// 5 c c c c v v c c c c + /// 6 c c c c c c c c c c + /// 7 c c c c c c c c c c + /// 8 c c c c c c c c c c + /// 9 c c c c c c c c c c + /// + /// Each c is a 100x100 container, v are containers visible in initial + /// viewport. + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 200.0, + child: new ListView( + controller: controllerY, + children: new List.generate(10, (int y) { + return Container( + height: 100.0, + child: new ListView( + scrollDirection: Axis.horizontal, + controller: controllersX[y], + children: children[y] = new List.generate(10, (int x) { + return new Container( + height: 100.0, + width: 100.0, + child: new Text('$x,$y'), + ); + }), + ), + ); + }), + ), + ), + ), + ), + ); + + // Already in viewport + tester.renderObject(find.byWidget(children[4][4], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[4].offset, 400.0); + expect(controllerY.offset, 400.0); + + controllersX[4].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above viewport + tester.renderObject(find.byWidget(children[3][4], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[3].offset, 400.0); + expect(controllerY.offset, 300.0); + + controllersX[3].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below viewport + tester.renderObject(find.byWidget(children[6][4], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[6].offset, 400.0); + expect(controllerY.offset, 500.0); + + controllersX[6].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Left of viewport + tester.renderObject(find.byWidget(children[4][3], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[4].offset, 300.0); + expect(controllerY.offset, 400.0); + + controllersX[4].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Right of viewport + tester.renderObject(find.byWidget(children[4][6], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[4].offset, 500.0); + expect(controllerY.offset, 400.0); + + controllersX[4].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above and left of viewport + tester.renderObject(find.byWidget(children[3][3], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[3].offset, 300.0); + expect(controllerY.offset, 300.0); + + controllersX[3].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and left of viewport + tester.renderObject(find.byWidget(children[6][3], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[6].offset, 300.0); + expect(controllerY.offset, 500.0); + + controllersX[6].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above and right of viewport + tester.renderObject(find.byWidget(children[3][6], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[3].offset, 500.0); + expect(controllerY.offset, 300.0); + + controllersX[3].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and right of viewport + tester.renderObject(find.byWidget(children[6][6], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllersX[6].offset, 500.0); + expect(controllerY.offset, 500.0); + + controllersX[6].jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and right of viewport with animations + tester.renderObject(find.byWidget(children[6][6], skipOffstage: false)).showOnScreen(duration: const Duration(seconds: 2)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + expect(controllersX[6].offset, greaterThan(400.0)); + expect(controllersX[6].offset, lessThan(500.0)); + expect(controllerY.offset, greaterThan(400.0)); + expect(controllerY.offset, lessThan(500.0)); + await tester.pumpAndSettle(); + expect(controllersX[6].offset, 500.0); + expect(controllerY.offset, 500.0); + }); + + group('Nested viewports (same orientation) showOnScreen', () { + List children; + + Future buildNestedScroller({WidgetTester tester, ScrollController inner, ScrollController outer}) { + return tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new ListView( + controller: outer, + children: [ + new Container( + height: 200.0, + ), + new Container( + height: 200.0, + width: 300.0, + child: new ListView( + controller: inner, + children: children = new List.generate(10, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('$i'), + ); + }), + ), + ), + new Container( + height: 200.0, + ) + ], + ), + ), + ), + ), + ); + } + + testWidgets('in view in inner, but not in outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 0.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[0], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(inner.offset, 0.0); + expect(outer.offset, 100.0); + }); + + testWidgets('not in view of neither inner nor outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 0.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[4], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(inner.offset, 300.0); + expect(outer.offset, 200.0); + }); + + testWidgets('in view in inner and outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(initialScrollOffset: 200.0); + final ScrollController outer = new ScrollController(initialScrollOffset: 200.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + + tester.renderObject(find.byWidget(children[2])).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + }); + + testWidgets('inner shown in outer, but item not visible', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(initialScrollOffset: 200.0); + final ScrollController outer = new ScrollController(initialScrollOffset: 200.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + + tester.renderObject(find.byWidget(children[5], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 400.0); + }); + + testWidgets('inner half shown in outer, item only visible in inner', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(initialScrollOffset: 100.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 100.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[1])).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 0.0); + }); + }); + + testWidgets('Viewport showOnScreen with objects larger than viewport', (WidgetTester tester) async { + List children; + ScrollController controller; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + child: new ListView( + controller: controller = new ScrollController(initialScrollOffset: 300.0), + children: children = new List.generate(20, (int i) { + return new Container( + height: 300.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ); + + expect(controller.offset, 300.0); + + // Already aligned with leading edge, nothing happens. + tester.renderObject(find.byWidget(children[1], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0); + + // Above leading edge aligns trailing edges + tester.renderObject(find.byWidget(children[0], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0); + + // Below trailing edge aligns leading edges + tester.renderObject(find.byWidget(children[1], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0); + + controller.jumpTo(250.0); + await tester.pumpAndSettle(); + expect(controller.offset, 250.0); + + // Partly visible across leading edge aligns trailing edges + tester.renderObject(find.byWidget(children[0], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 100.0); + + controller.jumpTo(150.0); + await tester.pumpAndSettle(); + expect(controller.offset, 150.0); + + // Partly visible across trailing edge aligns leading edges + tester.renderObject(find.byWidget(children[1], skipOffstage: false)).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 300.0); + }); +} diff --git a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart index 7280d4d64a..8265e65973 100644 --- a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart +++ b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart @@ -743,4 +743,127 @@ void main() { debugDefaultTargetPlatformOverride = null; }); }); + + testWidgets('ListWheelScrollView getOffsetToReveal', (WidgetTester tester) async { + List outerChildren; + final List innerChildren = new List(10); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 500.0, + width: 300.0, + child: new ListWheelScrollView( + controller: new ScrollController(initialScrollOffset: 300.0), + itemExtent: 100.0, + children: outerChildren = new List.generate(10, (int i) { + return new Container( + child: new Center( + child: innerChildren[i] = new Container( + height: 50.0, + width: 50.0, + child: new Text('Item $i'), + ), + ), + ); + }), + ), + ), + ), + ), + ); + + final RenderListWheelViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderListWheelViewport); + + // direct child of viewport + RenderObject target = tester.renderObject(find.byWidget(outerChildren[5])); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 200.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 200.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 240.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 240.0, 10.0, 10.0)); + + // descendant of viewport, not direct child + target = tester.renderObject(find.byWidget(innerChildren[5])); + revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(125.0, 225.0, 50.0, 50.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(125.0, 225.0, 50.0, 50.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(165.0, 265.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(165.0, 265.0, 10.0, 10.0)); + }); + + testWidgets('ListWheelScrollView showOnScreen', (WidgetTester tester) async { + List outerChildren; + final List innerChildren = new List(10); + ScrollController controller; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 500.0, + width: 300.0, + child: new ListWheelScrollView( + controller: controller = new ScrollController(initialScrollOffset: 300.0), + itemExtent: 100.0, + children: + outerChildren = new List.generate(10, (int i) { + return new Container( + child: new Center( + child: innerChildren[i] = new Container( + height: 50.0, + width: 50.0, + child: new Text('Item $i'), + ), + ), + ); + }), + ), + ), + ), + ), + ); + + expect(controller.offset, 300.0); + + tester.renderObject(find.byWidget(outerChildren[5])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 500.0); + + tester.renderObject(find.byWidget(innerChildren[9])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controller.offset, 900.0); + + tester.renderObject(find.byWidget(outerChildren[7])).showOnScreen(duration: const Duration(seconds: 2)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + expect(controller.offset, lessThan(900.0)); + expect(controller.offset, greaterThan(700.0)); + await tester.pumpAndSettle(); + expect(controller.offset, 700.0); + }); } diff --git a/packages/flutter/test/widgets/single_child_scroll_view_test.dart b/packages/flutter/test/widgets/single_child_scroll_view_test.dart index c543f9bee7..3b7d5a05b4 100644 --- a/packages/flutter/test/widgets/single_child_scroll_view_test.dart +++ b/packages/flutter/test/widgets/single_child_scroll_view_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'semantics_tester.dart'; @@ -341,4 +342,487 @@ void main() { semantics.dispose(); }); + + testWidgets('SingleChildScrollView getOffsetToReveal - down', (WidgetTester tester) async { + List children; + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new SingleChildScrollView( + controller: new ScrollController(initialScrollOffset: 300.0), + child: new Column( + children: children = new List.generate(20, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5])); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 540.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 350.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); + }); + + testWidgets('SingleChildScrollView getOffsetToReveal - up', (WidgetTester tester) async { + final List children = new List.generate(20, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('Tile $i'), + ); + }); + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new SingleChildScrollView( + controller: new ScrollController(initialScrollOffset: 300.0), + reverse: true, + child: new Column( + children: children.reversed.toList(), + ), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5])); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 100.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 300.0, 100.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 550.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 190.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 360.0); + expect(revealed.rect, new Rect.fromLTWH(40.0, 0.0, 10.0, 10.0)); + }); + + testWidgets('SingleChildScrollView getOffsetToReveal - right', (WidgetTester tester) async { + List children; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 300.0, + width: 200.0, + child: new SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: new ScrollController(initialScrollOffset: 300.0), + child: new Row( + children: children = new List.generate(20, (int i) { + return new Container( + height: 300.0, + width: 100.0, + child: new Text('Tile $i'), + ); + }), + ), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5])); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 540.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 350.0); + expect(revealed.rect, new Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); + }); + + testWidgets('SingleChildScrollView getOffsetToReveal - left', (WidgetTester tester) async { + final List children = new List.generate(20, (int i) { + return new Container( + height: 300.0, + width: 100.0, + child: new Text('Tile $i'), + ); + }); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 300.0, + width: 200.0, + child: new SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + controller: new ScrollController(initialScrollOffset: 300.0), + child: new Row( + children: children.reversed.toList(), + ), + ), + ), + ), + ), + ); + + final RenderAbstractViewport viewport = tester.allRenderObjects.firstWhere((RenderObject r) => r is RenderAbstractViewport); + + final RenderObject target = tester.renderObject(find.byWidget(children[5])); + RevealedOffset revealed = viewport.getOffsetToReveal(target, 0.0); + expect(revealed.offset, 500.0); + expect(revealed.rect, new Rect.fromLTWH(100.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0); + expect(revealed.offset, 400.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 0.0, 100.0, 300.0)); + + revealed = viewport.getOffsetToReveal(target, 0.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 550.0); + expect(revealed.rect, new Rect.fromLTWH(190.0, 40.0, 10.0, 10.0)); + + revealed = viewport.getOffsetToReveal(target, 1.0, rect: new Rect.fromLTWH(40.0, 40.0, 10.0, 10.0)); + expect(revealed.offset, 360.0); + expect(revealed.rect, new Rect.fromLTWH(0.0, 40.0, 10.0, 10.0)); + }); + + testWidgets('Nested SingleChildScrollView showOnScreen', (WidgetTester tester) async { + final List> children = new List>(10); + ScrollController controllerX; + ScrollController controllerY; + + /// Builds a gird: + /// + /// <- x -> + /// 0 1 2 3 4 5 6 7 8 9 + /// 0 c c c c c c c c c c + /// 1 c c c c c c c c c c + /// 2 c c c c c c c c c c + /// 3 c c c c c c c c c c y + /// 4 c c c c v v c c c c + /// 5 c c c c v v c c c c + /// 6 c c c c c c c c c c + /// 7 c c c c c c c c c c + /// 8 c c c c c c c c c c + /// 9 c c c c c c c c c c + /// + /// Each c is a 100x100 container, v are containers visible in initial + /// viewport. + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 200.0, + child: new SingleChildScrollView( + controller: controllerY = new ScrollController(initialScrollOffset: 400.0), + child: new SingleChildScrollView( + controller: controllerX = new ScrollController(initialScrollOffset: 400.0), + scrollDirection: Axis.horizontal, + child: new Column( + children: new List.generate(10, (int y) { + return new Row( + children: children[y] = new List.generate(10, (int x) { + return new Container( + height: 100.0, + width: 100.0, + ); + }) + ); + }), + ), + ), + ), + ), + ), + ), + ); + + expect(controllerX.offset, 400.0); + expect(controllerY.offset, 400.0); + + // Already in viewport + tester.renderObject(find.byWidget(children[4][4])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 400.0); + expect(controllerY.offset, 400.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above viewport + tester.renderObject(find.byWidget(children[3][4])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 400.0); + expect(controllerY.offset, 300.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below viewport + tester.renderObject(find.byWidget(children[6][4])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 400.0); + expect(controllerY.offset, 500.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Left of viewport + tester.renderObject(find.byWidget(children[4][3])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 300.0); + expect(controllerY.offset, 400.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Right of viewport + tester.renderObject(find.byWidget(children[4][6])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 500.0); + expect(controllerY.offset, 400.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above and left of viewport + tester.renderObject(find.byWidget(children[3][3])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 300.0); + expect(controllerY.offset, 300.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and left of viewport + tester.renderObject(find.byWidget(children[6][3])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 300.0); + expect(controllerY.offset, 500.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Above and right of viewport + tester.renderObject(find.byWidget(children[3][6])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 500.0); + expect(controllerY.offset, 300.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and right of viewport + tester.renderObject(find.byWidget(children[6][6])).showOnScreen(); + await tester.pumpAndSettle(); + expect(controllerX.offset, 500.0); + expect(controllerY.offset, 500.0); + + controllerX.jumpTo(400.0); + controllerY.jumpTo(400.0); + await tester.pumpAndSettle(); + + // Below and right of viewport with animations + tester.renderObject(find.byWidget(children[6][6])).showOnScreen(duration: const Duration(seconds: 2)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.hasRunningAnimations, isTrue); + expect(controllerX.offset, greaterThan(400.0)); + expect(controllerX.offset, lessThan(500.0)); + expect(controllerY.offset, greaterThan(400.0)); + expect(controllerY.offset, lessThan(500.0)); + await tester.pumpAndSettle(); + expect(controllerX.offset, 500.0); + expect(controllerY.offset, 500.0); + }); + + group('Nested SingleChildScrollView (same orientation) showOnScreen', () { + List children; + + Future buildNestedScroller({WidgetTester tester, ScrollController inner, ScrollController outer}) { + return tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Center( + child: Container( + height: 200.0, + width: 300.0, + child: new SingleChildScrollView( + controller: outer, + child: new Column( + children: [ + new Container( + height: 200.0, + ), + new Container( + height: 200.0, + width: 300.0, + child: new SingleChildScrollView( + controller: inner, + child: new Column( + children: children = new List.generate(10, (int i) { + return new Container( + height: 100.0, + width: 300.0, + child: new Text('$i'), + ); + }), + ), + ), + ), + new Container( + height: 200.0, + ) + ], + ), + ), + ), + ), + ), + ); + } + + testWidgets('in view in inner, but not in outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 0.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[0])).showOnScreen(); + await tester.pumpAndSettle(); + expect(inner.offset, 0.0); + expect(outer.offset, 100.0); + }); + + testWidgets('not in view of neither inner nor outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 0.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[5])).showOnScreen(); + await tester.pumpAndSettle(); + expect(inner.offset, 400.0); + expect(outer.offset, 200.0); + }); + + testWidgets('in view in inner and outer', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(initialScrollOffset: 200.0); + final ScrollController outer = new ScrollController(initialScrollOffset: 200.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + + tester.renderObject(find.byWidget(children[2])).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + }); + + testWidgets('inner shown in outer, but item not visible', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(initialScrollOffset: 200.0); + final ScrollController outer = new ScrollController(initialScrollOffset: 200.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 200.0); + expect(inner.offset, 200.0); + + tester.renderObject(find.byWidget(children[5])).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 400.0); + }); + + testWidgets('inner half shown in outer, item only visible in inner', (WidgetTester tester) async { + final ScrollController inner = new ScrollController(); + final ScrollController outer = new ScrollController(initialScrollOffset: 100.0); + await buildNestedScroller( + tester: tester, + inner: inner, + outer: outer, + ); + expect(outer.offset, 100.0); + expect(inner.offset, 0.0); + + tester.renderObject(find.byWidget(children[1])).showOnScreen(); + await tester.pumpAndSettle(); + expect(outer.offset, 200.0); + expect(inner.offset, 0.0); + }); + }); }