diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index a8e0d823f4..5c060fce23 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -120,9 +120,10 @@ typedef NestedScrollViewHeaderSliversBuilder = List Function(BuildContex /// top: false, /// bottom: false, /// child: Builder( -/// // This Builder is needed to provide a BuildContext that is "inside" -/// // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can -/// // find the NestedScrollView. +/// // This Builder is needed to provide a BuildContext that is +/// // "inside" the NestedScrollView, so that +/// // sliverOverlapAbsorberHandleFor() can find the +/// // NestedScrollView. /// builder: (BuildContext context) { /// return CustomScrollView( /// // The "controller" and "primary" members should be left @@ -136,7 +137,8 @@ typedef NestedScrollViewHeaderSliversBuilder = List Function(BuildContex /// key: PageStorageKey(name), /// slivers: [ /// SliverOverlapInjector( -/// // This is the flip side of the SliverOverlapAbsorber above. +/// // This is the flip side of the SliverOverlapAbsorber +/// // above. /// handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), /// ), /// SliverPadding( @@ -268,7 +270,10 @@ class NestedScrollView extends StatefulWidget { /// documentation. static SliverOverlapAbsorberHandle sliverOverlapAbsorberHandleFor(BuildContext context) { final _InheritedNestedScrollView target = context.dependOnInheritedWidgetOfExactType<_InheritedNestedScrollView>(); - assert(target != null, 'NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.'); + assert( + target != null, + 'NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.', + ); return target.state._absorberHandle; } @@ -285,18 +290,93 @@ class NestedScrollView extends StatefulWidget { } @override - _NestedScrollViewState createState() => _NestedScrollViewState(); + NestedScrollViewState createState() => NestedScrollViewState(); } -class _NestedScrollViewState extends State { +/// The [State] for a [NestedScrollView]. +/// +/// The [ScrollController]s, [innerController] and [outerController], of the +/// [NestedScrollView]'s children may be accessed through its state. This is +/// useful for obtaining respective scroll positions in the [NestedScrollView]. +/// +/// If you want to access the inner or outer scroll controller of a +/// [NestedScrollView], you can get its [NestedScrollViewState] by supplying a +/// `GlobalKey` to the [NestedScrollView.key] parameter). +/// +/// {@tool sample --template=stateless_widget_material} +/// [NestedScrollViewState] can be obtained using a [GlobalKey]. +/// Using the following setup, you can access the inner scroll controller +/// using `globalKey.currentState.innerController`. +/// +/// ```dart preamble +/// final GlobalKey globalKey = GlobalKey(); +/// ``` +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// return NestedScrollView( +/// key: globalKey, +/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { +/// return [ +/// SliverAppBar( +/// title: Text('NestedScrollViewState Demo!'), +/// ), +/// ]; +/// }, +/// body: CustomScrollView( +/// // Body slivers go here! +/// ), +/// ); +/// } +/// +/// ScrollController get innerController { +/// return globalKey.currentState.innerController; +/// } +/// ``` +/// {@end-tool} +class NestedScrollViewState extends State { final SliverOverlapAbsorberHandle _absorberHandle = SliverOverlapAbsorberHandle(); + /// The [ScrollController] provided to the [ScrollView] in + /// [NestedScrollView.body]. + /// + /// Manipulating the [ScrollPosition] of this controller pushes the outer + /// header sliver(s) up and out of view. The position of the [outerController] + /// will be set to [ScrollPosition.maxScrollExtent], unless you use + /// [ScrollPosition.setPixels]. + /// + /// See also: + /// + /// * [outerController], which exposes the [ScrollController] used by the + /// the sliver(s) contained in [NestedScrollView.headerSliverBuilder]. + ScrollController get innerController => _coordinator._innerController; + + /// The [ScrollController] provided to the [ScrollView] in + /// [NestedScrollView.headerSliverBuilder]. + /// + /// This is equivalent to [NestedScrollView.controller], if provided. + /// + /// Manipulating the [ScrollPosition] of this controller pushes the inner body + /// sliver(s) down. The position of the [innerController] will be set to + /// [ScrollPosition.minScrollExtent], unless you use + /// [ScrollPosition.setPixels]. Visually, the inner body will be scrolled to + /// its beginning. + /// + /// See also: + /// + /// * [innerController], which exposes the [ScrollController] used by the + /// [ScrollView] contained in [NestedScrollView.body]. + ScrollController get outerController => _coordinator._outerController; + _NestedScrollCoordinator _coordinator; @override void initState() { super.initState(); - _coordinator = _NestedScrollCoordinator(this, widget.controller, _handleHasScrolledBodyChanged); + _coordinator = _NestedScrollCoordinator( + this, widget.controller, + _handleHasScrolledBodyChanged, + ); } @override @@ -348,8 +428,8 @@ class _NestedScrollViewState extends State { scrollDirection: widget.scrollDirection, reverse: widget.reverse, physics: widget.physics != null - ? widget.physics.applyTo(const ClampingScrollPhysics()) - : const ClampingScrollPhysics(), + ? widget.physics.applyTo(const ClampingScrollPhysics()) + : const ClampingScrollPhysics(), controller: _coordinator._outerController, slivers: widget._buildSlivers( context, @@ -410,7 +490,7 @@ class _InheritedNestedScrollView extends InheritedWidget { assert(child != null), super(key: key, child: child); - final _NestedScrollViewState state; + final NestedScrollViewState state; @override bool updateShouldNotify(_InheritedNestedScrollView old) => state != old.state; @@ -469,11 +549,19 @@ typedef _NestedScrollActivityGetter = ScrollActivity Function(_NestedScrollPosit class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { _NestedScrollCoordinator(this._state, this._parent, this._onHasScrolledBodyChanged) { final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0; - _outerController = _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer'); - _innerController = _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner'); + _outerController = _NestedScrollController( + this, + initialScrollOffset: initialScrollOffset, + debugLabel: 'outer', + ); + _innerController = _NestedScrollController( + this, + initialScrollOffset: 0.0, + debugLabel: 'inner', + ); } - final _NestedScrollViewState _state; + final NestedScrollViewState _state; ScrollController _parent; final VoidCallback _onHasScrolledBodyChanged; @@ -550,14 +638,22 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont @override void goIdle() { - beginActivity(_createIdleScrollActivity(_outerPosition), _createIdleScrollActivity); + beginActivity( + _createIdleScrollActivity(_outerPosition), + _createIdleScrollActivity, + ); } @override void goBallistic(double velocity) { beginActivity( createOuterBallisticScrollActivity(velocity), - (_NestedScrollPosition position) => createInnerBallisticScrollActivity(position, velocity), + (_NestedScrollPosition position) { + return createInnerBallisticScrollActivity( + position, + velocity, + ); + }, ); } @@ -593,7 +689,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont if (innerPosition == null) { // It's either just us or a velocity=0 situation. return _outerPosition.createBallisticScrollActivity( - _outerPosition.physics.createBallisticSimulation(_outerPosition, velocity), + _outerPosition.physics.createBallisticSimulation( + _outerPosition, + velocity, + ), mode: _NestedBallisticScrollActivityMode.independent, ); } @@ -611,7 +710,9 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ScrollActivity createInnerBallisticScrollActivity(_NestedScrollPosition position, double velocity) { return position.createBallisticScrollActivity( position.physics.createBallisticSimulation( - velocity == 0 ? position as ScrollMetrics : _getMetrics(position, velocity), + velocity == 0 + ? position as ScrollMetrics + : _getMetrics(position, velocity), velocity, ), mode: _NestedBallisticScrollActivityMode.inner, @@ -622,7 +723,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont assert(innerPosition != null); double pixels, minRange, maxRange, correctionOffset, extra; if (innerPosition.pixels == innerPosition.minScrollExtent) { - pixels = _outerPosition.pixels.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent) as double; // TODO(ianh): gracefully handle out-of-range outer positions + pixels = _outerPosition.pixels.clamp( + _outerPosition.minScrollExtent, + _outerPosition.maxScrollExtent, + ) as double; // TODO(ianh): gracefully handle out-of-range outer positions minRange = _outerPosition.minScrollExtent; maxRange = _outerPosition.maxScrollExtent; assert(minRange <= maxRange); @@ -688,7 +792,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont double unnestOffset(double value, _NestedScrollPosition source) { if (source == _outerPosition) - return value.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent) as double; + return value.clamp( + _outerPosition.minScrollExtent, + _outerPosition.maxScrollExtent, + ) as double; if (value < source.minScrollExtent) return value - source.minScrollExtent + _outerPosition.minScrollExtent; return value - source.minScrollExtent + _outerPosition.maxScrollExtent; @@ -696,7 +803,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont double nestOffset(double value, _NestedScrollPosition target) { if (target == _outerPosition) - return value.clamp(_outerPosition.minScrollExtent, _outerPosition.maxScrollExtent) as double; + return value.clamp( + _outerPosition.minScrollExtent, + _outerPosition.maxScrollExtent, + ) as double; if (value < _outerPosition.minScrollExtent) return value - _outerPosition.minScrollExtent + target.minScrollExtent; if (value > _outerPosition.maxScrollExtent) @@ -711,7 +821,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont for (final _NestedScrollPosition position in _innerPositions) { if (!position.haveDimensions) return; - maxInnerExtent = math.max(maxInnerExtent, position.maxScrollExtent - position.minScrollExtent); + maxInnerExtent = math.max( + maxInnerExtent, + position.maxScrollExtent - position.minScrollExtent, + ); } _outerPosition.updateCanDrag(maxInnerExtent); } @@ -758,7 +871,10 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ScrollHoldController hold(VoidCallback holdCancelCallback) { beginActivity( - HoldScrollActivity(delegate: _outerPosition, onHoldCanceled: holdCancelCallback), + HoldScrollActivity( + delegate: _outerPosition, + onHoldCanceled: holdCancelCallback, + ), (_NestedScrollPosition position) => HoldScrollActivity(delegate: position), ); return this; @@ -786,7 +902,9 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont @override void applyUserOffset(double delta) { - updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse); + updateUserScrollDirection( + delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse + ); assert(delta != 0.0); if (_innerPositions.isEmpty) { _outerPosition.applyFullDragUpdate(delta); @@ -805,7 +923,8 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont } } else { // dragging "down" - delta is positive - // prioritize the inner views, so that the inner content will move before the app bar grows + // prioritize the inner views, so that the inner content will move before + // the app bar grows double outerDelta = 0.0; // it will go positive if it changes final List overscrolls = []; final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); @@ -831,7 +950,9 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont } void updateParent() { - _outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_state.context)); + _outerPosition?.setParent( + _parent ?? PrimaryScrollController.of(_state.context) + ); } @mustCallSuper @@ -984,9 +1105,13 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele // One is if the physics allow it, via applyFullDragUpdate (see below). An // overscroll situation can also be forced, e.g. if the scroll position is // artificially set using the scroll controller. - final double min = delta < 0.0 ? -double.infinity : math.min(minScrollExtent, pixels); + final double min = delta < 0.0 + ? -double.infinity + : math.min(minScrollExtent, pixels); // The logic for max is equivalent but on the other side. - final double max = delta > 0.0 ? double.infinity : math.max(maxScrollExtent, pixels); + final double max = delta > 0.0 + ? double.infinity + : math.max(maxScrollExtent, pixels); final double oldPixels = pixels; final double newPixels = (pixels - delta).clamp(min, max) as double; final double clampedDelta = newPixels - pixels; @@ -1007,7 +1132,10 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele assert(delta != 0.0); final double oldPixels = pixels; // Apply friction: - final double newPixels = pixels - physics.applyPhysicsToUserOffset(this, delta); + final double newPixels = pixels - physics.applyPhysicsToUserOffset( + this, + delta, + ); if (oldPixels == newPixels) return 0.0; // delta must have been so small we dropped it during floating point addition // Check for overscroll: @@ -1050,7 +1178,8 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele beginActivity(IdleScrollActivity(this)); } - // This is called by activities when they finish their work and want to go ballistic. + // This is called by activities when they finish their work and want to go + // ballistic. @override void goBallistic(double velocity) { Simulation simulation; @@ -1075,9 +1204,20 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele assert(metrics != null); if (metrics.minRange == metrics.maxRange) return IdleScrollActivity(this); - return _NestedOuterBallisticScrollActivity(coordinator, this, metrics, simulation, context.vsync); + return _NestedOuterBallisticScrollActivity( + coordinator, + this, + metrics, + simulation, + context.vsync, + ); case _NestedBallisticScrollActivityMode.inner: - return _NestedInnerBallisticScrollActivity(coordinator, this, simulation, context.vsync); + return _NestedInnerBallisticScrollActivity( + coordinator, + this, + simulation, + context.vsync, + ); case _NestedBallisticScrollActivityMode.independent: return BallisticScrollActivity(this, simulation, context.vsync); } @@ -1090,7 +1230,11 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele @required Duration duration, @required Curve curve, }) { - return coordinator.animateTo(coordinator.unnestOffset(to, this), duration: duration, curve: curve); + return coordinator.animateTo( + coordinator.unnestOffset(to, this), + duration: duration, + curve: curve, + ); } @override @@ -1157,12 +1301,18 @@ class _NestedInnerBallisticScrollActivity extends BallisticScrollActivity { @override void resetActivity() { - delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity)); + delegate.beginActivity(coordinator.createInnerBallisticScrollActivity( + delegate, + velocity, + )); } @override void applyNewDimensions() { - delegate.beginActivity(coordinator.createInnerBallisticScrollActivity(delegate, velocity)); + delegate.beginActivity(coordinator.createInnerBallisticScrollActivity( + delegate, + velocity, + )); } @override @@ -1190,12 +1340,16 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity { @override void resetActivity() { - delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity)); + delegate.beginActivity( + coordinator.createOuterBallisticScrollActivity(velocity) + ); } @override void applyNewDimensions() { - delegate.beginActivity(coordinator.createOuterBallisticScrollActivity(velocity)); + delegate.beginActivity( + coordinator.createOuterBallisticScrollActivity(velocity) + ); } @override @@ -1290,7 +1444,10 @@ class SliverOverlapAbsorberHandle extends ChangeNotifier { double _scrollExtent; void _setExtents(double layoutValue, double scrollValue) { - assert(_writers == 1, 'Multiple RenderSliverOverlapAbsorbers have been provided the same SliverOverlapAbsorberHandle.'); + assert( + _writers == 1, + 'Multiple RenderSliverOverlapAbsorbers have been provided the same SliverOverlapAbsorberHandle.', + ); _layoutExtent = layoutValue; _scrollExtent = scrollValue; } @@ -1434,7 +1591,10 @@ class RenderSliverOverlapAbsorber extends RenderSliver with RenderObjectWithChil @override void performLayout() { - assert(handle._writers == 1, 'A SliverOverlapAbsorberHandle cannot be passed to multiple RenderSliverOverlapAbsorber objects at the same time.'); + assert( + handle._writers == 1, + 'A SliverOverlapAbsorberHandle cannot be passed to multiple RenderSliverOverlapAbsorber objects at the same time.', + ); if (child == null) { geometry = const SliverGeometry(); return; @@ -1453,7 +1613,10 @@ class RenderSliverOverlapAbsorber extends RenderSliver with RenderObjectWithChil hasVisualOverflow: childLayoutGeometry.hasVisualOverflow, scrollOffsetCorrection: childLayoutGeometry.scrollOffsetCorrection, ); - handle._setExtents(childLayoutGeometry.maxScrollObstructionExtent, childLayoutGeometry.maxScrollObstructionExtent); + handle._setExtents( + childLayoutGeometry.maxScrollObstructionExtent, + childLayoutGeometry.maxScrollObstructionExtent, + ); } @override @@ -1464,7 +1627,11 @@ class RenderSliverOverlapAbsorber extends RenderSliver with RenderObjectWithChil @override bool hitTestChildren(SliverHitTestResult result, { @required double mainAxisPosition, @required double crossAxisPosition }) { if (child != null) - return child.hitTest(result, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition); + return child.hitTest( + result, + mainAxisPosition: mainAxisPosition, + crossAxisPosition: crossAxisPosition, + ); return false; } @@ -1599,7 +1766,10 @@ class RenderSliverOverlapInjector extends RenderSliver { void performLayout() { _currentLayoutExtent = handle.layoutExtent; _currentMaxExtent = handle.layoutExtent; - final double clampedLayoutExtent = math.min(_currentLayoutExtent - constraints.scrollOffset, constraints.remainingPaintExtent); + final double clampedLayoutExtent = math.min( + _currentLayoutExtent - constraints.scrollOffset, + constraints.remainingPaintExtent, + ); geometry = SliverGeometry( scrollExtent: _currentLayoutExtent, paintExtent: math.max(0.0, clampedLayoutExtent), @@ -1631,7 +1801,14 @@ class RenderSliverOverlapInjector extends RenderSliver { break; } for (int index = -2; index <= 2; index += 1) { - paintZigZag(context.canvas, paint, start - delta * index.toDouble(), end - delta * index.toDouble(), 10, 10.0); + paintZigZag( + context.canvas, + paint, + start - delta * index.toDouble(), + end - delta * index.toDouble(), + 10, + 10.0, + ); } } return true; @@ -1680,7 +1857,10 @@ class NestedScrollViewViewport extends Viewport { RenderNestedScrollViewViewport createRenderObject(BuildContext context) { return RenderNestedScrollViewViewport( axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), + crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection( + context, + axisDirection, + ), anchor: anchor, offset: offset, handle: handle, @@ -1691,7 +1871,10 @@ class NestedScrollViewViewport extends Viewport { void updateRenderObject(BuildContext context, RenderNestedScrollViewViewport renderObject) { renderObject ..axisDirection = axisDirection - ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection) + ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection( + context, + axisDirection, + ) ..anchor = anchor ..offset = offset ..handle = handle; @@ -1709,7 +1892,8 @@ class NestedScrollViewViewport extends Viewport { /// This viewport takes a [SliverOverlapAbsorberHandle] and notifies it any time /// the viewport needs to recompute its layout (e.g. when it is scrolled). class RenderNestedScrollViewViewport extends RenderViewport { - /// Create a variant of [RenderViewport] that has a [SliverOverlapAbsorberHandle]. + /// Create a variant of [RenderViewport] that has a + /// [SliverOverlapAbsorberHandle]. /// /// The [handle] must not be null. RenderNestedScrollViewViewport({ diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index fa7ed6c95b..87e6bf5aaa 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -22,7 +22,12 @@ class _CustomPhysics extends ClampingScrollPhysics { } } -Widget buildTest({ ScrollController controller, String title = 'TTTTTTTT' }) { +Widget buildTest({ + ScrollController controller, + String title = 'TTTTTTTT', + Key key, + bool expanded = true, +}) { return Localizations( locale: const Locale('en', 'US'), delegates: const >[ @@ -38,6 +43,7 @@ Widget buildTest({ ScrollController controller, String title = 'TTTTTTTT' }) { body: DefaultTabController( length: 4, child: NestedScrollView( + key: key, dragStartBehavior: DragStartBehavior.down, controller: controller, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { @@ -45,7 +51,7 @@ Widget buildTest({ ScrollController controller, String title = 'TTTTTTTT' }) { SliverAppBar( title: Text(title), pinned: true, - expandedHeight: 200.0, + expandedHeight: expanded ? 200.0 : 0.0, forceElevated: innerBoxIsScrolled, bottom: const TabBar( tabs: [ @@ -119,7 +125,10 @@ void main() { final Offset point1 = tester.getCenter(find.text('aaa1')); await tester.dragFrom(point1, const Offset(0.0, 200.0)); await tester.pump(); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); await tester.flingFrom(point1, const Offset(0.0, -80.0), 50000.0); await tester.pump(const Duration(milliseconds: 20)); final Offset point2 = tester.getCenter(find.text('aaa1')); @@ -128,6 +137,7 @@ void main() { // the following expectation should switch to 200.0. expect(tester.renderObject(find.byType(AppBar)).size.height, 120.0); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async { await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); @@ -147,11 +157,14 @@ void main() { expect(find.text('aaa2'), findsNothing); await tester.pump(const Duration(milliseconds: 1000)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('NestedScrollView overscroll and release', (WidgetTester tester) async { await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); await tester.pump(const Duration(milliseconds: 500)); - final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('aaa1'))); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.text('aaa1')) + ); await gesture1.moveBy(const Offset(0.0, 200.0)); await tester.pumpAndSettle(); expect(find.text('aaa2'), findsNothing); @@ -169,19 +182,31 @@ void main() { expect(find.text('aaa3'), findsNothing); expect(find.text('bbb1'), findsNothing); await tester.pump(const Duration(milliseconds: 250)); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); await tester.drag(find.text('AA'), const Offset(0.0, -20.0)); await tester.pump(const Duration(milliseconds: 250)); - expect(tester.renderObject(find.byType(AppBar)).size.height, 180.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 180.0, + ); await tester.drag(find.text('AA'), const Offset(0.0, -20.0)); await tester.pump(const Duration(milliseconds: 250)); - expect(tester.renderObject(find.byType(AppBar)).size.height, 160.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 160.0, + ); await tester.drag(find.text('AA'), const Offset(0.0, -20.0)); await tester.pump(const Duration(milliseconds: 250)); - expect(tester.renderObject(find.byType(AppBar)).size.height, 140.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 140.0, + ); expect(find.text('aaa4'), findsNothing); await tester.pump(const Duration(milliseconds: 250)); @@ -203,17 +228,25 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 250)); expect(find.text('bbb1'), findsNothing); expect(find.text('ccc1'), findsOneWidget); - expect(tester.renderObject(find.byType(AppBar)).size.height, minHeight); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + minHeight, + ); await tester.pump(const Duration(milliseconds: 250)); await tester.fling(find.text('AA'), const Offset(0.0, 50.0), 10000.0); await tester.pumpAndSettle(const Duration(milliseconds: 250)); expect(find.text('ccc1'), findsOneWidget); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); }); testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async { - final ScrollController controller = ScrollController(initialScrollOffset: 50.0); + final ScrollController controller = ScrollController( + initialScrollOffset: 50.0, + ); double scrollOffset; controller.addListener(() { @@ -226,26 +259,45 @@ void main() { expect(controller.position.maxScrollExtent, 200.0); // The appbar's expandedHeight - initialScrollOffset = 150. - expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 150.0, + ); // Fully expand the appbar by scrolling (no animation) to 0.0. controller.jumpTo(0.0); await tester.pumpAndSettle(); expect(scrollOffset, 0.0); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); // Scroll back to 50.0 animating over 100ms. - controller.animateTo(50.0, duration: const Duration(milliseconds: 100), curve: Curves.linear); + controller.animateTo( + 50.0, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); await tester.pump(); await tester.pump(); expect(scrollOffset, 0.0); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); await tester.pump(const Duration(milliseconds: 50)); // 50ms - halfway to scroll offset = 50.0. expect(scrollOffset, 25.0); - expect(tester.renderObject(find.byType(AppBar)).size.height, 175.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 175.0, + ); await tester.pump(const Duration(milliseconds: 50)); // 100ms - all the way to scroll offset = 50.0. expect(scrollOffset, 50.0); - expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 150.0, + ); // Scroll to the end, (we're not scrolling to the end of the list that contains aaa1, // just to the end of the outer scrollview). Verify that the first item in each tab @@ -288,12 +340,18 @@ void main() { expect(find.text('Page0'), findsOneWidget); expect(find.text('Page1'), findsNothing); expect(find.text('Page2'), findsNothing); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); // A scroll collapses Page0's appbar to 150.0. controller.jumpTo(50.0); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 150.0, + ); // Fling to Page1. Page1's appbar height is the same as the appbar for Page0. await tester.fling(find.text('Page0'), const Offset(-100.0, 0.0), 10000.0); @@ -301,19 +359,28 @@ void main() { expect(find.text('Page0'), findsNothing); expect(find.text('Page1'), findsOneWidget); expect(find.text('Page2'), findsNothing); - expect(tester.renderObject(find.byType(AppBar)).size.height, 150.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 150.0, + ); // Expand Page1's appbar and then fling to Page2. Page2's appbar appears // fully expanded. controller.jumpTo(0.0); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); await tester.fling(find.text('Page1'), const Offset(-100.0, 0.0), 10000.0); await tester.pumpAndSettle(); expect(find.text('Page0'), findsNothing); expect(find.text('Page1'), findsNothing); expect(find.text('Page2'), findsOneWidget); - expect(tester.renderObject(find.byType(AppBar)).size.height, 200.0); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 200.0, + ); }); testWidgets('NestedScrollViews with custom physics', (WidgetTester tester) async { @@ -347,7 +414,10 @@ void main() { final Offset point1 = tester.getCenter(find.text('AA')); await tester.dragFrom(point1, const Offset(0.0, 200.0)); await tester.pump(const Duration(milliseconds: 20)); - final Offset point2 = tester.getCenter(find.text('AA', skipOffstage: false)); + final Offset point2 = tester.getCenter(find.text( + 'AA', + skipOffstage: false, + )); expect(point1.dy, greaterThan(point2.dy)); }); @@ -368,29 +438,31 @@ void main() { // These are the slivers that show up in the "outer" scroll view. return [ SliverOverlapAbsorber( - // This widget takes the overlapping behavior of the SliverAppBar, - // and redirects it to the SliverOverlapInjector below. If it is - // missing, then it is possible for the nested "inner" scroll view - // below to end up under the SliverAppBar even when the inner - // scroll view thinks it has not been scrolled. - // This is not necessary if the "headerSliverBuilder" only builds - // widgets that do not overlap the next sliver. + // This widget takes the overlapping behavior of the + // SliverAppBar, and redirects it to the SliverOverlapInjector + // below. If it is missing, then it is possible for the nested + // "inner" scroll view below to end up under the SliverAppBar + // even when the inner scroll view thinks it has not been + // scrolled. This is not necessary if the + // "headerSliverBuilder" only builds widgets that do not + // overlap the next sliver. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( title: const Text('Books'), // This is the title in the app bar. pinned: true, expandedHeight: 150.0, - // The "forceElevated" property causes the SliverAppBar to show - // a shadow. The "innerBoxIsScrolled" parameter is true when the - // inner scroll view is scrolled beyond its "zero" point, i.e. - // when it appears to be scrolled below the SliverAppBar. - // Without this, there are cases where the shadow would appear - // or not appear inappropriately, because the SliverAppBar is - // not actually aware of the precise position of the inner - // scroll views. + // The "forceElevated" property causes the SliverAppBar to + // show a shadow. The "innerBoxIsScrolled" parameter is true + // when the inner scroll view is scrolled beyond its "zero" + // point, i.e. when it appears to be scrolled below the + // SliverAppBar. Without this, there are cases where the + // shadow would appear or not appear inappropriately, + // because the SliverAppBar is not actually aware of the + // precise position of the inner scroll views. forceElevated: innerBoxIsScrolled, bottom: TabBar( - // These are the widgets to put in each tab in the tab bar. + // These are the widgets to put in each tab in the tab + // bar. tabs: _tabs.map((String name) => Tab(text: name)).toList(), dragStartBehavior: DragStartBehavior.down, ), @@ -406,24 +478,27 @@ void main() { top: false, bottom: false, child: Builder( - // This Builder is needed to provide a BuildContext that is "inside" - // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can - // find the NestedScrollView. + // This Builder is needed to provide a BuildContext that is + // "inside" the NestedScrollView, so that + // sliverOverlapAbsorberHandleFor() can find the + // NestedScrollView. builder: (BuildContext context) { return CustomScrollView( // The "controller" and "primary" members should be left // unset, so that the NestedScrollView can control this // inner scroll view. // If the "controller" property is set, then this scroll - // view will not be associated with the NestedScrollView. - // The PageStorageKey should be unique to this ScrollView; - // it allows the list to remember its scroll position when - // the tab view is not on the screen. + // view will not be associated with the + // NestedScrollView. The PageStorageKey should be unique + // to this ScrollView; it allows the list to remember + // its scroll position when the tab view is not on the + // screen. key: PageStorageKey(name), dragStartBehavior: DragStartBehavior.down, slivers: [ SliverOverlapInjector( - // This is the flip side of the SliverOverlapAbsorber above. + // This is the flip side of the + // SliverOverlapAbsorber above. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), ), SliverPadding( @@ -431,24 +506,27 @@ void main() { // In this example, the inner scroll view has // fixed-height list items, hence the use of // SliverFixedExtentList. However, one could use any - // sliver widget here, e.g. SliverList or SliverGrid. + // sliver widget here, e.g. SliverList or + // SliverGrid. sliver: SliverFixedExtentList( - // The items in this example are fixed to 48 pixels - // high. This matches the Material Design spec for - // ListTile widgets. + // The items in this example are fixed to 48 + // pixels high. This matches the Material Design + // spec for ListTile widgets. itemExtent: 48.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { // This builder is called for each child. - // In this example, we just number each list item. + // In this example, we just number each list + // item. return ListTile( title: Text('Item $index'), ); }, - // The childCount of the SliverChildBuilderDelegate - // specifies how many children this inner list - // has. In this example, each tab has a list of - // exactly 30 items, but this is arbitrary. + // The childCount of the + // SliverChildBuilderDelegate specifies how many + // children this inner list has. In this + // example, each tab has a list of exactly 30 + // items, but this is arbitrary. childCount: 30, ), ), @@ -498,7 +576,9 @@ void main() { expect(find.text('Item 18'), findsNothing); _checkPhysicalLayer(elevation: 0); // scroll down - final TestGesture gesture0 = await tester.startGesture(tester.getCenter(find.text('Item 2'))); + final TestGesture gesture0 = await tester.startGesture( + tester.getCenter(find.text('Item 2')) + ); await gesture0.moveBy(const Offset(0.0, -120.0)); // tiny bit more than the pinned app bar height (56px * 2) await tester.pump(); expect(buildCount, expectedBuildCount); @@ -515,7 +595,9 @@ void main() { expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 4); // scroll down - final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.text('Item 2'))); + final TestGesture gesture1 = await tester.startGesture( + tester.getCenter(find.text('Item 2')) + ); await gesture1.moveBy(const Offset(0.0, -800.0)); await tester.pump(); expect(buildCount, expectedBuildCount); @@ -527,15 +609,22 @@ void main() { expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 4); // swipe left to bring in tap on the right - final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView))); + final TestGesture gesture2 = await tester.startGesture( + tester.getCenter(find.byType(NestedScrollView)) + ); await gesture2.moveBy(const Offset(-400.0, 0.0)); await tester.pump(); expect(buildCount, expectedBuildCount); expect(find.text('Item 18'), findsOneWidget); expect(find.text('Item 2'), findsOneWidget); expect(find.text('Item 0'), findsOneWidget); - expect(tester.getTopLeft(find.ancestor(of: find.text('Item 0'), matching: find.byType(ListTile))).dy, - tester.getBottomLeft(find.byType(AppBar)).dy + 8.0); + expect(tester.getTopLeft( + find.ancestor( + of: find.text('Item 0'), + matching: find.byType(ListTile), + )).dy, + tester.getBottomLeft(find.byType(AppBar)).dy + 8.0, + ); _checkPhysicalLayer(elevation: 4); await gesture2.up(); await tester.pump(); // start sideways scroll @@ -552,7 +641,9 @@ void main() { await tester.pump(const Duration(seconds: 1)); // just checking we don't rebuild... expect(buildCount, expectedBuildCount); // peek left to see it's still in the right place - final TestGesture gesture3 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView))); + final TestGesture gesture3 = await tester.startGesture( + tester.getCenter(find.byType(NestedScrollView)) + ); await gesture3.moveBy(const Offset(400.0, 0.0)); await tester.pump(); // bring the left page into view expect(buildCount, expectedBuildCount); @@ -577,7 +668,9 @@ void main() { expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 0); // scroll back up - final TestGesture gesture4 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView))); + final TestGesture gesture4 = await tester.startGesture( + tester.getCenter(find.byType(NestedScrollView)) + ); await gesture4.moveBy(const Offset(0.0, 200.0)); // expands the appbar again await tester.pump(); expect(buildCount, expectedBuildCount); @@ -589,7 +682,9 @@ void main() { expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 0); // peek left to see it's now back at zero - final TestGesture gesture5 = await tester.startGesture(tester.getCenter(find.byType(NestedScrollView))); + final TestGesture gesture5 = await tester.startGesture( + tester.getCenter(find.byType(NestedScrollView)) + ); await gesture5.moveBy(const Offset(400.0, 0.0)); await tester.pump(); // bring the left page into view await tester.pump(); // shadow would come back starting here, but there's no shadow to show @@ -640,39 +735,467 @@ void main() { ), ), ); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); - expect(tester.getRect(find.byKey(key2)), const Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0)); - final TestGesture gesture = await tester.startGesture(const Offset(10.0, 10.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0), + ); + expect( + tester.getRect(find.byKey(key2)), + const Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0), + ); + final TestGesture gesture = await tester.startGesture( + const Offset(10.0, 10.0) + ); await gesture.moveBy(const Offset(0.0, -10.0)); // scroll up await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, -10.0, 800.0, 100.0)); - expect(tester.getRect(find.byKey(key2)), const Rect.fromLTWH(0.0, 90.0, 800.0, 1000.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, -10.0, 800.0, 100.0), + ); + expect( + tester.getRect(find.byKey(key2)), + const Rect.fromLTWH(0.0, 90.0, 800.0, 1000.0), + ); await gesture.moveBy(const Offset(0.0, 10.0)); // scroll back to origin await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); - expect(tester.getRect(find.byKey(key2)), const Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0), + ); + expect( + tester.getRect(find.byKey(key2)), + const Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0), + ); await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0), + ); expect(tester.getRect(find.byKey(key2)).top, greaterThan(100.0)); expect(tester.getRect(find.byKey(key2)).top, lessThan(130.0)); await gesture.moveBy(const Offset(0.0, -1.0)); // scroll back a little await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, -1.0, 800.0, 100.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, -1.0, 800.0, 100.0), + ); expect(tester.getRect(find.byKey(key2)).top, greaterThan(100.0)); expect(tester.getRect(find.byKey(key2)).top, lessThan(129.0)); await gesture.moveBy(const Offset(0.0, -10.0)); // scroll back a lot await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, -11.0, 800.0, 100.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, -11.0, 800.0, 100.0), + ); await gesture.moveBy(const Offset(0.0, 20.0)); // overscroll again await tester.pump(); - expect(tester.getRect(find.byKey(key1)), const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + expect( + tester.getRect(find.byKey(key1)), + const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0), + ); await gesture.up(); debugDefaultTargetPlatformOverride = null; }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + group('NestedScrollViewState exposes inner and outer controllers', () { + testWidgets('Scrolling by less than the outer extent does not scroll the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey, + expanded: false, + )); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 104.0); + final double scrollExtent = appBarHeight - 50.0; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is not an expanded AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, 54.0); + // the inner scroll controller should not have scrolled. + expect(globalKey.currentState.innerController.offset, 0.0); + }); + + testWidgets('Scrolling by exactly the outer extent does not scroll the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey, + expanded: false, + )); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 104.0); + final double scrollExtent = appBarHeight; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is not an expanded AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, 104.0); + // the inner scroll controller should not have scrolled. + expect(globalKey.currentState.innerController.offset, 0.0); + }); + + testWidgets('Scrolling by greater than the outer extent scrolls the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey, + expanded: false, + )); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 104.0); + final double scrollExtent = appBarHeight + 50.0; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is not an expanded AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, appBarHeight); + // the inner scroll controller should have scrolled equivalent to the + // difference between the applied scrollExtent and the outer extent. + expect( + globalKey.currentState.innerController.offset, + scrollExtent - appBarHeight, + ); + }); + + testWidgets('scrolling by less than the expanded outer extent does not scroll the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest(key: globalKey)); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 200.0); + final double scrollExtent = appBarHeight - 50.0; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is an expanding AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, 150.0); + // the inner scroll controller should not have scrolled. + expect(globalKey.currentState.innerController.offset, 0.0); + }); + + testWidgets('scrolling by exactly the expanded outer extent does not scroll the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest(key: globalKey)); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 200.0); + final double scrollExtent = appBarHeight; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is an expanding AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, 200.0); + // the inner scroll controller should not have scrolled. + expect(globalKey.currentState.innerController.offset, 0.0); + }); + + testWidgets('scrolling by greater than the expanded outer extent scrolls the inner body', (WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + await tester.pumpWidget(buildTest(key: globalKey)); + + double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + expect(appBarHeight, 200.0); + final double scrollExtent = appBarHeight + 50.0; + expect(globalKey.currentState.outerController.offset, 0.0); + expect(globalKey.currentState.innerController.offset, 0.0); + + // The scroll gesture should occur in the inner body, so the whole + // scroll view is scrolled. + final TestGesture gesture = await tester.startGesture(Offset( + 0.0, + appBarHeight + 1.0, + )); + await gesture.moveBy(Offset(0.0, -scrollExtent)); + await tester.pump(); + + appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + // This is an expanding AppBar. + expect(appBarHeight, 104.0); + // The outer scroll controller should show an offset of the applied + // scrollExtent. + expect(globalKey.currentState.outerController.offset, 200.0); + // the inner scroll controller should have scrolled equivalent to the + // difference between the applied scrollExtent and the outer extent. + expect(globalKey.currentState.innerController.offset, 50.0); + }); + + testWidgets('NestedScrollViewState.outerController should correspond to NestedScrollView.controller', ( + WidgetTester tester) async { + final GlobalKey globalKey = GlobalKey(); + final ScrollController scrollController = ScrollController(); + + await tester.pumpWidget(buildTest( + controller: scrollController, + key: globalKey, + )); + + // Scroll to compare offsets between controllers. + final TestGesture gesture = await tester.startGesture(const Offset( + 0.0, + 100.0, + )); + await gesture.moveBy(const Offset(0.0, -100.0)); + await tester.pump(); + + expect( + scrollController.offset, + globalKey.currentState.outerController.offset, + ); + expect( + tester.widget(find.byType(NestedScrollView)).controller.offset, + globalKey.currentState.outerController.offset, + ); + }); + + group('manipulating controllers when', () { + testWidgets('outer: not scrolled, inner: not scrolled', (WidgetTester tester) async { + final GlobalKey globalKey1 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey1, + expanded: false, + )); + expect(globalKey1.currentState.outerController.position.pixels, 0.0); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + final double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + + // Manipulating Inner + globalKey1.currentState.innerController.jumpTo(100.0); + expect(globalKey1.currentState.innerController.position.pixels, 100.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + globalKey1.currentState.innerController.jumpTo(0.0); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + + // Reset + final GlobalKey globalKey2 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey2, + expanded: false, + )); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + + // Manipulating Outer + globalKey2.currentState.outerController.jumpTo(100.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 100.0); + globalKey2.currentState.outerController.jumpTo(0.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + }); + + testWidgets('outer: not scrolled, inner: scrolled', (WidgetTester tester) async { + final GlobalKey globalKey1 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey1, + expanded: false, + )); + expect(globalKey1.currentState.outerController.position.pixels, 0.0); + globalKey1.currentState.innerController.position.setPixels(10.0); + expect(globalKey1.currentState.innerController.position.pixels, 10.0); + final double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + + // Manipulating Inner + globalKey1.currentState.innerController.jumpTo(100.0); + expect(globalKey1.currentState.innerController.position.pixels, 100.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + globalKey1.currentState.innerController.jumpTo(0.0); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + + // Reset + final GlobalKey globalKey2 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey2, + expanded: false, + )); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + globalKey2.currentState.innerController.position.setPixels(10.0); + expect(globalKey2.currentState.innerController.position.pixels, 10.0); + + // Manipulating Outer + globalKey2.currentState.outerController.jumpTo(100.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 100.0); + globalKey2.currentState.outerController.jumpTo(0.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + }); + + testWidgets('outer: scrolled, inner: not scrolled', (WidgetTester tester) async { + final GlobalKey globalKey1 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey1, + expanded: false, + )); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + globalKey1.currentState.outerController.position.setPixels(10.0); + expect(globalKey1.currentState.outerController.position.pixels, 10.0); + final double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + + // Manipulating Inner + globalKey1.currentState.innerController.jumpTo(100.0); + expect(globalKey1.currentState.innerController.position.pixels, 100.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + globalKey1.currentState.innerController.jumpTo(0.0); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + + // Reset + final GlobalKey globalKey2 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey2, + expanded: false, + )); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + globalKey2.currentState.outerController.position.setPixels(10.0); + expect(globalKey2.currentState.outerController.position.pixels, 10.0); + + // Manipulating Outer + globalKey2.currentState.outerController.jumpTo(100.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 100.0); + globalKey2.currentState.outerController.jumpTo(0.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + }); + + testWidgets('outer: scrolled, inner: scrolled', (WidgetTester tester) async { + final GlobalKey globalKey1 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey1, + expanded: false, + )); + globalKey1.currentState.innerController.position.setPixels(10.0); + expect(globalKey1.currentState.innerController.position.pixels, 10.0); + globalKey1.currentState.outerController.position.setPixels(10.0); + expect(globalKey1.currentState.outerController.position.pixels, 10.0); + final double appBarHeight = tester.renderObject(find.byType(AppBar)).size.height; + + // Manipulating Inner + globalKey1.currentState.innerController.jumpTo(100.0); + expect(globalKey1.currentState.innerController.position.pixels, 100.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + globalKey1.currentState.innerController.jumpTo(0.0); + expect(globalKey1.currentState.innerController.position.pixels, 0.0); + expect( + globalKey1.currentState.outerController.position.pixels, + appBarHeight, + ); + + // Reset + final GlobalKey globalKey2 = GlobalKey(); + await tester.pumpWidget(buildTest( + key: globalKey2, + expanded: false, + )); + globalKey2.currentState.innerController.position.setPixels(10.0); + expect(globalKey2.currentState.innerController.position.pixels, 10.0); + globalKey2.currentState.outerController.position.setPixels(10.0); + expect(globalKey2.currentState.outerController.position.pixels, 10.0); + + // Manipulating Outer + globalKey2.currentState.outerController.jumpTo(100.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 100.0); + globalKey2.currentState.outerController.jumpTo(0.0); + expect(globalKey2.currentState.innerController.position.pixels, 0.0); + expect(globalKey2.currentState.outerController.position.pixels, 0.0); + }); + }); + }); + // Regression test for https://github.com/flutter/flutter/issues/39963. testWidgets('NestedScrollView with SliverOverlapAbsorber in or out of the first screen', (WidgetTester tester) async { await tester.pumpWidget(const _TestLayoutExtentIsNegative(1));