// Copyright 2016 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 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/rendering.dart'; class _CustomPhysics extends ClampingScrollPhysics { const _CustomPhysics({ ScrollPhysics parent }) : super(parent: parent); @override _CustomPhysics applyTo(ScrollPhysics ancestor) { return _CustomPhysics(parent: buildParent(ancestor)); } @override Simulation createBallisticSimulation(ScrollMetrics position, double dragVelocity) { return ScrollSpringSimulation(spring, 1000.0, 1000.0, 1000.0); } } Widget buildTest({ ScrollController controller, String title = 'TTTTTTTT' }) { return Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: const MediaQueryData(), child: Scaffold( drawerDragStartBehavior: DragStartBehavior.down, body: DefaultTabController( length: 4, child: NestedScrollView( dragStartBehavior: DragStartBehavior.down, controller: controller, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( title: Text(title), pinned: true, expandedHeight: 200.0, forceElevated: innerBoxIsScrolled, bottom: const TabBar( tabs: <Tab>[ Tab(text: 'AA'), Tab(text: 'BB'), Tab(text: 'CC'), Tab(text: 'DD'), ], ), ), ]; }, body: TabBarView( children: <Widget>[ ListView( children: <Widget>[ Container( height: 300.0, child: const Text('aaa1'), ), Container( height: 200.0, child: const Text('aaa2'), ), Container( height: 100.0, child: const Text('aaa3'), ), Container( height: 50.0, child: const Text('aaa4'), ), ], ), ListView( dragStartBehavior: DragStartBehavior.down, children: <Widget>[ Container( height: 100.0, child: const Text('bbb1'), ), ], ), Container( child: const Center(child: Text('ccc1')), ), ListView( dragStartBehavior: DragStartBehavior.down, children: <Widget>[ Container( height: 10000.0, child: const Text('ddd1'), ), ], ), ], ), ), ), ), ), ), ); } void main() { testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); await tester.pump(const Duration(milliseconds: 250)); final Offset point1 = tester.getCenter(find.text('aaa1')); await tester.dragFrom(point1, const Offset(0.0, 200.0)); await tester.pump(); expect(tester.renderObject<RenderBox>(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')); expect(point2.dy, greaterThan(point1.dy)); // TODO(ianh): Once we improve how we handle scrolling down from overscroll, // the following expectation should switch to 200.0. expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 120.0); debugDefaultTargetPlatformOverride = null; }); testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); await tester.pump(const Duration(milliseconds: 250)); final Offset point = tester.getCenter(find.text('aaa1')); await tester.flingFrom(point, const Offset(0.0, 200.0), 5000.0); await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('aaa2'), findsNothing); final TestGesture gesture1 = await tester.startGesture(point); await tester.pump(const Duration(milliseconds: 5000)); expect(find.text('aaa2'), findsNothing); await gesture1.moveBy(const Offset(0.0, 50.0)); await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10)); expect(find.text('aaa2'), findsNothing); await tester.pump(const Duration(milliseconds: 1000)); debugDefaultTargetPlatformOverride = null; }); testWidgets('NestedScrollView overscroll and release', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; 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'))); await gesture1.moveBy(const Offset(0.0, 200.0)); await tester.pumpAndSettle(); expect(find.text('aaa2'), findsNothing); await tester.pump(const Duration(seconds: 1)); await gesture1.up(); await tester.pumpAndSettle(); expect(find.text('aaa2'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }, skip: true); // https://github.com/flutter/flutter/issues/9040 testWidgets('NestedScrollView', (WidgetTester tester) async { await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); expect(find.text('aaa3'), findsNothing); expect(find.text('bbb1'), findsNothing); await tester.pump(const Duration(milliseconds: 250)); expect(tester.renderObject<RenderBox>(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<RenderBox>(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<RenderBox>(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<RenderBox>(find.byType(AppBar)).size.height, 140.0); expect(find.text('aaa4'), findsNothing); 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('aaa4'), findsOneWidget); final double minHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height; expect(minHeight, lessThan(140.0)); await tester.pump(const Duration(milliseconds: 250)); await tester.tap(find.text('BB')); await tester.pumpAndSettle(const Duration(milliseconds: 250)); expect(find.text('aaa4'), findsNothing); expect(find.text('bbb1'), findsOneWidget); await tester.pump(const Duration(milliseconds: 250)); await tester.tap(find.text('CC')); await tester.pumpAndSettle(const Duration(milliseconds: 250)); expect(find.text('bbb1'), findsNothing); expect(find.text('ccc1'), findsOneWidget); expect(tester.renderObject<RenderBox>(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<RenderBox>(find.byType(AppBar)).size.height, 200.0); }); testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async { final ScrollController controller = ScrollController(initialScrollOffset: 50.0); double scrollOffset; controller.addListener(() { scrollOffset = controller.offset; }); await tester.pumpWidget(buildTest(controller: controller)); expect(controller.position.minScrollExtent, 0.0); expect(controller.position.pixels, 50.0); expect(controller.position.maxScrollExtent, 200.0); // The appbar's expandedHeight - initialScrollOffset = 150. expect(tester.renderObject<RenderBox>(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<RenderBox>(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); await tester.pump(); await tester.pump(); expect(scrollOffset, 0.0); expect(tester.renderObject<RenderBox>(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<RenderBox>(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<RenderBox>(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 // is still visible. controller.jumpTo(controller.position.maxScrollExtent); await tester.pumpAndSettle(); expect(scrollOffset, 200.0); expect(find.text('aaa1'), findsOneWidget); await tester.tap(find.text('BB')); await tester.pumpAndSettle(); expect(find.text('bbb1'), findsOneWidget); await tester.tap(find.text('CC')); await tester.pumpAndSettle(); expect(find.text('ccc1'), findsOneWidget); await tester.tap(find.text('DD')); await tester.pumpAndSettle(); expect(find.text('ddd1'), findsOneWidget); }); testWidgets('Three NestedScrollViews with one ScrollController', (WidgetTester tester) async { final TrackingScrollController controller = TrackingScrollController(); expect(controller.mostRecentlyUpdatedPosition, isNull); expect(controller.initialScrollOffset, 0.0); await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: PageView( children: <Widget>[ buildTest(controller: controller, title: 'Page0'), buildTest(controller: controller, title: 'Page1'), buildTest(controller: controller, title: 'Page2'), ], ), )); // Initially Page0 is visible and Page0's appbar is fully expanded (height = 200.0). expect(find.text('Page0'), findsOneWidget); expect(find.text('Page1'), findsNothing); expect(find.text('Page2'), findsNothing); expect(tester.renderObject<RenderBox>(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<RenderBox>(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); await tester.pumpAndSettle(); expect(find.text('Page0'), findsNothing); expect(find.text('Page1'), findsOneWidget); expect(find.text('Page2'), findsNothing); expect(tester.renderObject<RenderBox>(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<RenderBox>(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<RenderBox>(find.byType(AppBar)).size.height, 200.0); }); testWidgets('NestedScrollViews with custom physics', (WidgetTester tester) async { await tester.pumpWidget(Directionality( textDirection: TextDirection.ltr, child: Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: MediaQuery( data: const MediaQueryData(), child: NestedScrollView( physics: const _CustomPhysics(), headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ const SliverAppBar( floating: true, title: Text('AA'), ), ]; }, body: Container(), ), ), ), )); expect(find.text('AA'), findsOneWidget); await tester.pump(const Duration(milliseconds: 500)); 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)); expect(point1.dy, greaterThan(point2.dy)); }); testWidgets('NestedScrollView and internal scrolling', (WidgetTester tester) async { debugDisableShadows = false; const List<String> _tabs = <String>['Hello', 'World']; int buildCount = 0; await tester.pumpWidget( MaterialApp(home: Material(child: // THE FOLLOWING SECTION IS FROM THE NestedScrollView DOCUMENTATION // (EXCEPT FOR THE CHANGES TO THE buildCount COUNTER) DefaultTabController( length: _tabs.length, // This is the number of tabs. child: NestedScrollView( dragStartBehavior: DragStartBehavior.down, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { buildCount += 1; // THIS LINE IS NOT IN THE ORIGINAL -- ADDED FOR TEST // These are the slivers that show up in the "outer" scroll view. return <Widget>[ 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. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), child: 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. forceElevated: innerBoxIsScrolled, bottom: TabBar( // These are the widgets to put in each tab in the tab bar. tabs: _tabs.map<Widget>((String name) => Tab(text: name)).toList(), dragStartBehavior: DragStartBehavior.down, ), ), ), ]; }, body: TabBarView( dragStartBehavior: DragStartBehavior.down, // These are the contents of the tab views, below the tabs. children: _tabs.map<Widget>((String name) { return SafeArea( 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. 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. key: PageStorageKey<String>(name), dragStartBehavior: DragStartBehavior.down, slivers: <Widget>[ SliverOverlapInjector( // This is the flip side of the SliverOverlapAbsorber above. handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), ), SliverPadding( padding: const EdgeInsets.all(8.0), // 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: SliverFixedExtentList( // 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. 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. childCount: 30, ), ), ), ], ); }, ), ); }).toList(), ), ), ), // END )), ); PhysicalModelLayer _dfsFindPhysicalLayer(ContainerLayer layer) { expect(layer, isNotNull); Layer child = layer.firstChild; while (child != null) { if (child is PhysicalModelLayer) { return child; } if (child is ContainerLayer) { final PhysicalModelLayer candidate = _dfsFindPhysicalLayer(child); if (candidate != null) { return candidate; } } child = child.nextSibling; } return null; } final ContainerLayer nestedScrollViewLayer = find.byType(NestedScrollView).evaluate().first.renderObject.debugLayer; void _checkPhysicalLayer({@required double elevation}) { final PhysicalModelLayer layer = _dfsFindPhysicalLayer(nestedScrollViewLayer); expect(layer, isNotNull); expect(layer.elevation, equals(elevation)); } int expectedBuildCount = 0; expectedBuildCount += 1; expect(buildCount, expectedBuildCount); expect(find.text('Item 2'), findsOneWidget); expect(find.text('Item 18'), findsNothing); _checkPhysicalLayer(elevation: 0); // scroll down 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); expect(find.text('Item 2'), findsOneWidget); expect(find.text('Item 18'), findsNothing); await gesture0.up(); await tester.pump(const Duration(milliseconds: 1)); // start shadow animation expectedBuildCount += 1; expect(buildCount, expectedBuildCount); await tester.pump(const Duration(milliseconds: 1)); // during shadow animation expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 0.00018262863159179688); await tester.pump(const Duration(seconds: 1)); // end shadow animation expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 4); // scroll down 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); _checkPhysicalLayer(elevation: 4); expect(find.text('Item 2'), findsNothing); expect(find.text('Item 18'), findsOneWidget); await gesture1.up(); await tester.pump(const Duration(seconds: 1)); 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))); 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); _checkPhysicalLayer(elevation: 4); await gesture2.up(); await tester.pump(); // start sideways scroll await tester.pump(const Duration(seconds: 1)); // end sideways scroll, triggers shadow going away expect(buildCount, expectedBuildCount); await tester.pump(const Duration(seconds: 1)); // start shadow going away expectedBuildCount += 1; expect(buildCount, expectedBuildCount); await tester.pump(const Duration(seconds: 1)); // end shadow going away expect(buildCount, expectedBuildCount); expect(find.text('Item 18'), findsNothing); expect(find.text('Item 2'), findsOneWidget); _checkPhysicalLayer(elevation: 0); 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))); await gesture3.moveBy(const Offset(400.0, 0.0)); await tester.pump(); // bring the left page into view expect(buildCount, expectedBuildCount); await tester.pump(); // shadow comes back starting here expectedBuildCount += 1; expect(buildCount, expectedBuildCount); expect(find.text('Item 18'), findsOneWidget); expect(find.text('Item 2'), findsOneWidget); _checkPhysicalLayer(elevation: 0); await tester.pump(const Duration(seconds: 1)); // shadow finishes coming back expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 4); await gesture3.moveBy(const Offset(-400.0, 0.0)); await gesture3.up(); await tester.pump(); // left tab view goes away expect(buildCount, expectedBuildCount); await tester.pump(); // shadow goes away starting here expectedBuildCount += 1; expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 4); await tester.pump(const Duration(seconds: 1)); // shadow finishes going away expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 0); // scroll back up 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); expect(find.text('Item 2'), findsOneWidget); expect(find.text('Item 18'), findsNothing); _checkPhysicalLayer(elevation: 0); await gesture4.up(); await tester.pump(const Duration(seconds: 1)); 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))); 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 expect(buildCount, expectedBuildCount); expect(find.text('Item 18'), findsNothing); expect(find.text('Item 2'), findsNWidgets(2)); _checkPhysicalLayer(elevation: 0); await tester.pump(const Duration(seconds: 1)); // shadow would be finished coming back _checkPhysicalLayer(elevation: 0); await gesture5.up(); await tester.pump(); // right tab view goes away await tester.pumpAndSettle(); expect(buildCount, expectedBuildCount); _checkPhysicalLayer(elevation: 0); debugDisableShadows = true; }); testWidgets('NestedScrollView and iOS bouncing', (WidgetTester tester) async { // This verifies that overscroll bouncing works correctly on iOS. For // example, this checks that if you pull to overscroll, friction is applied; // it also makes sure that if you scroll back the other way, the scroll // positions of the inner and outer list don't have a discontinuity. debugDefaultTargetPlatformOverride = TargetPlatform.iOS; const Key key1 = ValueKey<int>(1); const Key key2 = ValueKey<int>(2); await tester.pumpWidget( MaterialApp( home: Material( child: DefaultTabController( length: 1, child: NestedScrollView( dragStartBehavior: DragStartBehavior.down, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ const SliverPersistentHeader( delegate: TestHeader(key: key1), ), ]; }, body: SingleChildScrollView( dragStartBehavior: DragStartBehavior.down, child: Container( height: 1000.0, child: const Placeholder(key: key2), ), ), ), ), ), ), ); 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)); 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)); 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(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(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)); 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)); await gesture.up(); debugDefaultTargetPlatformOverride = null; }); } class TestHeader extends SliverPersistentHeaderDelegate { const TestHeader({ this.key }); final Key key; @override double get minExtent => 100.0; @override double get maxExtent => 100.0; @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Placeholder(key: key); } @override bool shouldRebuild(TestHeader oldDelegate) => false; }