// Copyright 2014 The Flutter 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' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/rendering_tester.dart' show TestClipPaintingContext; class _CustomPhysics extends ClampingScrollPhysics { const _CustomPhysics({ super.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', Key? key, bool expanded = true, }) { 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( key: key, dragStartBehavior: DragStartBehavior.down, controller: controller, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverAppBar( title: Text(title), pinned: true, expandedHeight: expanded ? 200.0 : 0.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: const <Widget>[ SizedBox( height: 300.0, child: Text('aaa1'), ), SizedBox( height: 200.0, child: Text('aaa2'), ), SizedBox( height: 100.0, child: Text('aaa3'), ), SizedBox( height: 50.0, child: Text('aaa4'), ), ], ), ListView( dragStartBehavior: DragStartBehavior.down, children: const <Widget>[ SizedBox( height: 100.0, child: Text('bbb1'), ), ], ), const Center(child: Text('ccc1')), ListView( dragStartBehavior: DragStartBehavior.down, children: const <Widget>[ SizedBox( height: 10000.0, child: Text('ddd1'), ), ], ), ], ), ), ), ), ), ), ); } void main() { testWidgets('ScrollDirection test', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/107101 final List<ScrollDirection> receivedResult = <ScrollDirection>[]; const List<ScrollDirection> expectedReverseResult = <ScrollDirection>[ScrollDirection.reverse, ScrollDirection.idle]; const List<ScrollDirection> expectedForwardResult = <ScrollDirection>[ScrollDirection.forward, ScrollDirection.idle]; await tester.pumpWidget(MaterialApp( home: Scaffold( body: NotificationListener<UserScrollNotification>( onNotification: (UserScrollNotification notification) { if (notification.depth != 1) { return true; } receivedResult.add(notification.direction); return true; }, child: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) => <Widget>[ const SliverAppBar( expandedHeight: 250.0, pinned: true, ), ], body: ListView.builder( padding: const EdgeInsets.all(8), itemCount: 30, itemBuilder: (BuildContext context, int index) { return SizedBox( height: 50, child: Center(child: Text('Item $index')), ); }, ), ), ), ), )); // Fling down to trigger ballistic activity await tester.fling(find.text('Item 3'), const Offset(0.0, -250.0), 10000.0); await tester.pumpAndSettle(); expect(receivedResult, expectedReverseResult); receivedResult.clear(); // Drag forward, without ballistic activity await tester.drag(find.text('Item 29'), const Offset(0.0, 20.0)); await tester.pump(); expect(receivedResult, expectedForwardResult); }); testWidgets('NestedScrollView respects clipBehavior', (WidgetTester tester) async { Widget build(NestedScrollView nestedScrollView) { 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: nestedScrollView, ), ), ); } await tester.pumpWidget(build( NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) => <Widget>[const SliverAppBar()], body: Container(height: 2000.0), ), )); // 1st, check that the render object has received the default clip behavior. final RenderNestedScrollViewViewport renderObject = tester.allRenderObjects.whereType<RenderNestedScrollViewViewport>().first; expect(renderObject.clipBehavior, equals(Clip.hardEdge)); // 2nd, check that the painting context has received the default clip behavior. final TestClipPaintingContext context = TestClipPaintingContext(); renderObject.paint(context, Offset.zero); expect(context.clipBehavior, equals(Clip.hardEdge)); // 3rd, pump a new widget to check that the render object can update its clip behavior. await tester.pumpWidget(build( NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) => <Widget>[const SliverAppBar()], body: Container(height: 2000.0), clipBehavior: Clip.antiAlias, ), )); expect(renderObject.clipBehavior, equals(Clip.antiAlias)); // 4th, check that a non-default clip behavior can be sent to the painting context. renderObject.paint(context, Offset.zero); expect(context.clipBehavior, equals(Clip.antiAlias)); }); testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async { await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); await tester.pump(const Duration(milliseconds: 250)); final Offset point1 = tester.getCenter(find.text('aaa1')); if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { await tester.dragFrom(point1, const Offset(0.0, 400.0)); } else { 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)); expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async { await tester.pumpWidget(buildTest()); expect(find.text('aaa2'), findsOneWidget); await tester.pump(const Duration(milliseconds: 250)); final Offset point = tester.getCenter(find.text('aaa1')); if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) { await tester.flingFrom(point, const Offset(0.0, 200.0), 15000.0); } else { 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)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ 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')), ); 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); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); 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, ); late 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), 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. 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 )), ); Object? dfsFindPhysicalLayer(RenderObject object) { expect(object, isNotNull); if (object is RenderPhysicalModel || object is RenderPhysicalShape) { return object; } final List<RenderObject> children = <RenderObject>[]; object.visitChildren(children.add); for (final RenderObject child in children) { final Object? result = dfsFindPhysicalLayer(child); if (result != null) { return result; } } return null; } final RenderObject nestedScrollViewLayer = find.byType(NestedScrollView).evaluate().first.renderObject!; void checkPhysicalLayer({required double elevation}) { final dynamic physicalModel = dfsFindPhysicalLayer(nestedScrollViewLayer); expect(physicalModel, isNotNull); // ignore: avoid_dynamic_calls expect(physicalModel.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 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. 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: const SingleChildScrollView( dragStartBehavior: DragStartBehavior.down, child: SizedBox( height: 1000.0, child: 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, 0.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, 0.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; }, variant: const TargetPlatformVariant(<TargetPlatform>{ 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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest( key: globalKey, expanded: false, )); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest( key: globalKey, expanded: false, )); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest( key: globalKey, expanded: false, )); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest(key: globalKey)); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest(key: globalKey)); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> globalKey = GlobalKey(); await tester.pumpWidget(buildTest(key: globalKey)); double appBarHeight = tester.renderObject<RenderBox>(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<RenderBox>(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<NestedScrollViewState> 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<NestedScrollView>(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<NestedScrollViewState> 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<RenderBox>(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<NestedScrollViewState> 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<NestedScrollViewState> 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<RenderBox>(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<NestedScrollViewState> 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<NestedScrollViewState> 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<RenderBox>(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<NestedScrollViewState> 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<NestedScrollViewState> 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<RenderBox>(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<NestedScrollViewState> 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)); await tester.pumpWidget(const _TestLayoutExtentIsNegative(10)); }); group('NestedScrollView can float outer sliver with inner scroll view:', () { Widget buildFloatTest({ GlobalKey? appBarKey, GlobalKey? nestedKey, ScrollController? controller, bool floating = false, bool pinned = false, bool snap = false, bool nestedFloat = false, bool expanded = false, }) { return MaterialApp( home: Scaffold( body: NestedScrollView( key: nestedKey, controller: controller, floatHeaderSlivers: nestedFloat, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( key: appBarKey, title: const Text('Test Title'), floating: floating, pinned: pinned, snap: snap, expandedHeight: expanded ? 200.0 : 0.0, ), ), ]; }, body: Builder( builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[ SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => ListTile(title: Text('Item $index')), childCount: 30, ), ), ], ); }, ), ), ), ); } double verifyGeometry({ required GlobalKey key, required double paintExtent, bool extentGreaterThan = false, bool extentLessThan = false, required bool visible, }) { final RenderSliver target = key.currentContext!.findRenderObject()! as RenderSliver; final SliverGeometry geometry = target.geometry!; expect(target.parent, isA<RenderSliverOverlapAbsorber>()); expect(geometry.visible, visible); if (extentGreaterThan) { expect(geometry.paintExtent, greaterThan(paintExtent)); } else if (extentLessThan) { expect(geometry.paintExtent, lessThan(paintExtent)); } else { expect(geometry.paintExtent, paintExtent); } return geometry.paintExtent; } testWidgets('float', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, nestedFloat: true, appBarKey: appBarKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // We will not scroll back the same amount to indicate that we are // floating in before reaching the top of the inner scrollable. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -300.0)); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scrollable should float back in, inner should not change await tester.dragFrom(point1, const Offset(0.0, 50.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); // Float the rest of the way in. await tester.dragFrom(point1, const Offset(0.0, 150.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); testWidgets('float expanded', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, nestedFloat: true, expanded: true, appBarKey: appBarKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // We will not scroll back the same amount to indicate that we are // floating in before reaching the top of the inner scrollable. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -300.0)); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scrollable should float back in, inner should not change // On initial float in, the app bar is collapsed. await tester.dragFrom(point1, const Offset(0.0, 50.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); // The inner scrollable should receive leftover delta after the outer has // been scrolled back in fully. await tester.dragFrom(point1, const Offset(0.0, 200.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); }); testWidgets('float with pointer signal', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, nestedFloat: true, appBarKey: appBarKey, )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // We will not scroll back the same amount to indicate that we are // floating in before reaching the top of the inner scrollable. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scrollable should float back in, inner should not change await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); // Float the rest of the way in. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -150.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); testWidgets('snap with pointer signal', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, snap: true, appBarKey: appBarKey, )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // We will not scroll back the same amount to indicate that we are // snapping in before reaching the top of the inner scrollable. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The snap animation should be triggered to expand the app bar await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); await tester.pumpAndSettle(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away a bit more to trigger the snap close animation. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 30.0))); await tester.pumpAndSettle(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect(find.byType(AppBar), findsNothing); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); }); testWidgets('float expanded with pointer signal', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, nestedFloat: true, expanded: true, appBarKey: appBarKey, )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // We will not scroll back the same amount to indicate that we are // floating in before reaching the top of the inner scrollable. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scrollable should float back in, inner should not change // On initial float in, the app bar is collapsed. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); // The inner scrollable should receive leftover delta after the outer has // been scrolled back in fully. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -200.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); }); testWidgets('only snap', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); final GlobalKey<NestedScrollViewState> nestedKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, snap: true, appBarKey: appBarKey, nestedKey: nestedKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll down the list, the app bar should scroll away and no longer be // visible. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -300.0)); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scroll view should be at its full extent, here the size of // the app bar. expect(nestedKey.currentState!.outerController.offset, 56.0); // Animate In // Drag the scrollable up and down. The app bar should not snap open, nor // should it float in. final TestGesture animateInGesture = await tester.startGesture(point1); await animateInGesture.moveBy(const Offset(0.0, 100.0)); // Should not float in await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 56.0); await animateInGesture.moveBy(const Offset(0.0, -50.0)); // No float out await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 56.0); // Trigger the snap open animation: drag down and release await animateInGesture.moveBy(const Offset(0.0, 10.0)); await animateInGesture.up(); // Now verify that the appbar is animating open await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); double lastExtent = verifyGeometry( key: appBarKey, paintExtent: 10.0, // >10.0 since 0.0 + 10.0 extentGreaterThan: true, visible: true, ); // The outer scroll offset should remain unchanged. expect(nestedKey.currentState!.outerController.offset, 56.0); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry( key: appBarKey, paintExtent: lastExtent, extentGreaterThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 56.0); // The animation finishes when the appbar is full height. await tester.pumpAndSettle(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); expect(nestedKey.currentState!.outerController.offset, 56.0); // Animate Out // Trigger the snap close animation: drag up and release final TestGesture animateOutGesture = await tester.startGesture(point1); await animateOutGesture.moveBy(const Offset(0.0, -10.0)); await animateOutGesture.up(); // Now verify that the appbar is animating closed await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); lastExtent = verifyGeometry( key: appBarKey, paintExtent: 46.0, // <46.0 since 56.0 - 10.0 extentLessThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 56.0); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry( key: appBarKey, paintExtent: lastExtent, extentLessThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 56.0); // The animation finishes when the appbar is no longer in view. await tester.pumpAndSettle(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 56.0); }); testWidgets('only snap expanded', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); final GlobalKey<NestedScrollViewState> nestedKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, snap: true, expanded: true, appBarKey: appBarKey, nestedKey: nestedKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); // Scroll down the list, the app bar should scroll away and no longer be // visible. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -400.0)); await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); // The outer scroll view should be at its full extent, here the size of // the app bar. expect(nestedKey.currentState!.outerController.offset, 200.0); // Animate In // Drag the scrollable up and down. The app bar should not snap open, nor // should it float in. final TestGesture animateInGesture = await tester.startGesture(point1); await animateInGesture.moveBy(const Offset(0.0, 100.0)); // Should not float in await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 200.0); await animateInGesture.moveBy(const Offset(0.0, -50.0)); // No float out await tester.pump(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 200.0); // Trigger the snap open animation: drag down and release await animateInGesture.moveBy(const Offset(0.0, 10.0)); await animateInGesture.up(); // Now verify that the appbar is animating open await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); double lastExtent = verifyGeometry( key: appBarKey, paintExtent: 10.0, // >10.0 since 0.0 + 10.0 extentGreaterThan: true, visible: true, ); // The outer scroll offset should remain unchanged. expect(nestedKey.currentState!.outerController.offset, 200.0); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry( key: appBarKey, paintExtent: lastExtent, extentGreaterThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 200.0); // The animation finishes when the appbar is full height. await tester.pumpAndSettle(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); expect(nestedKey.currentState!.outerController.offset, 200.0); // Animate Out // Trigger the snap close animation: drag up and release final TestGesture animateOutGesture = await tester.startGesture(point1); await animateOutGesture.moveBy(const Offset(0.0, -10.0)); await animateOutGesture.up(); // Now verify that the appbar is animating closed await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); lastExtent = verifyGeometry( key: appBarKey, paintExtent: 190.0, // <190.0 since 200.0 - 10.0 extentLessThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 200.0); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry( key: appBarKey, paintExtent: lastExtent, extentLessThan: true, visible: true, ); expect(nestedKey.currentState!.outerController.offset, 200.0); // The animation finishes when the appbar is no longer in view. await tester.pumpAndSettle(); expect(find.text('Test Title'), findsNothing); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); expect(nestedKey.currentState!.outerController.offset, 200.0); }); testWidgets('float pinned', (WidgetTester tester) async { // This configuration should have the same behavior of a pinned app bar. // No floating should happen, and the app bar should persist. final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, pinned: true, nestedFloat: true, appBarKey: appBarKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -300.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); await tester.dragFrom(point1, const Offset(0.0, 50.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); await tester.dragFrom(point1, const Offset(0.0, 150.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); testWidgets('float pinned expanded', (WidgetTester tester) async { // Only the expanded portion (flexible space) of the app bar should float // in and out. final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, pinned: true, expanded: true, nestedFloat: true, appBarKey: appBarKey, )); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // The expanded portion of the app bar should collapse. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -300.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll back some, the app bar should expand. await tester.dragFrom(point1, const Offset(0.0, 50.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 106.0, // 56.0 + 50.0 ); verifyGeometry(key: appBarKey, paintExtent: 106.0, visible: true); // Finish scrolling the rest of the way in. await tester.dragFrom(point1, const Offset(0.0, 150.0)); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); }); testWidgets('float pinned with pointer signal', (WidgetTester tester) async { // This configuration should have the same behavior of a pinned app bar. // No floating should happen, and the app bar should persist. final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, pinned: true, nestedFloat: true, appBarKey: appBarKey, )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -150.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); testWidgets('float pinned expanded with pointer signal', (WidgetTester tester) async { // Only the expanded portion (flexible space) of the app bar should float // in and out. final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( floating: true, pinned: true, expanded: true, nestedFloat: true, appBarKey: appBarKey, )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); // Scroll away the outer scroll view and some of the inner scroll view. // The expanded portion of the app bar should collapse. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 56.0, ); verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); // Scroll back some, the app bar should expand. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 106.0, // 56.0 + 50.0 ); verifyGeometry(key: appBarKey, paintExtent: 106.0, visible: true); // Finish scrolling the rest of the way in. await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -150.0))); await tester.pump(); expect(find.text('Test Title'), findsOneWidget); expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 5'), findsOneWidget); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0, ); verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); }); }); group('Correctly handles 0 velocity inner ballistic scroll activity:', () { // Regression tests for https://github.com/flutter/flutter/issues/17096 Widget buildBallisticTest(ScrollController controller) { return MaterialApp( home: Scaffold( body: NestedScrollView( controller: controller, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ const SliverAppBar( pinned: true, expandedHeight: 200.0, ), ]; }, body: ListView.builder( itemCount: 50, itemBuilder: (BuildContext context, int index) { return Padding( padding: const EdgeInsets.all(8.0), child: Text('Item $index'), ); }, ), ), ), ); } testWidgets('overscroll, hold for 0 velocity, and release', (WidgetTester tester) async { // Dragging into an overscroll and holding so that when released, the // ballistic scroll activity has a 0 velocity. final ScrollController controller = ScrollController(); await tester.pumpWidget(buildBallisticTest(controller)); // Last item of the inner scroll view. expect(find.text('Item 49'), findsNothing); // Scroll to bottom await tester.fling(find.text('Item 3'), const Offset(0.0, -50.0), 10000.0); await tester.pumpAndSettle(); // End of list expect(find.text('Item 49'), findsOneWidget); expect(tester.getCenter(find.text('Item 49')).dy, equals(585.0)); // Overscroll, dragging like this will release with 0 velocity. await tester.drag(find.text('Item 49'), const Offset(0.0, -50.0)); await tester.pump(); // If handled correctly, the last item should still be visible and // progressing back down to the bottom edge, instead of jumping further // up the list and out of view. expect(find.text('Item 49'), findsOneWidget); await tester.pumpAndSettle(); expect(tester.getCenter(find.text('Item 49')).dy, equals(585.0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })); testWidgets('overscroll, release, and tap', (WidgetTester tester) async { // Tapping while an inner ballistic scroll activity is in progress will // trigger a secondary ballistic scroll activity with a 0 velocity. final ScrollController controller = ScrollController(); await tester.pumpWidget(buildBallisticTest(controller)); // Last item of the inner scroll view. expect(find.text('Item 49'), findsNothing); // Scroll to bottom await tester.fling(find.text('Item 3'), const Offset(0.0, -50.0), 10000.0); await tester.pumpAndSettle(); // End of list expect(find.text('Item 49'), findsOneWidget); expect(tester.getCenter(find.text('Item 49')).dy, equals(585.0)); // Fling again to trigger first ballistic activity. await tester.fling(find.text('Item 48'), const Offset(0.0, -50.0), 10000.0); await tester.pump(); // Tap after releasing the overscroll to trigger secondary inner ballistic // scroll activity with 0 velocity. await tester.tap(find.text('Item 49'), warnIfMissed: false); await tester.pumpAndSettle(); // If handled correctly, the ballistic scroll activity should finish // closing out the overscrolled area, with the last item visible at the // bottom. expect(find.text('Item 49'), findsOneWidget); expect(tester.getCenter(find.text('Item 49')).dy, equals(585.0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS })); }); // Regression test for https://github.com/flutter/flutter/issues/63978 testWidgets('Inner _NestedScrollPosition.applyClampedDragUpdate correctly calculates range when in overscroll', (WidgetTester tester) async { final GlobalKey<NestedScrollViewState> nestedScrollView = GlobalKey(); await tester.pumpWidget(MaterialApp( home: Scaffold( body: NestedScrollView( key: nestedScrollView, headerSliverBuilder: (BuildContext context, bool boxIsScrolled) { return <Widget>[ const SliverAppBar( expandedHeight: 200, title: Text('Test'), ), ]; }, body: ListView.builder( itemExtent: 100.0, itemBuilder: (BuildContext context, int index) => Container( padding: const EdgeInsets.all(10.0), child: Material( color: index.isEven ? Colors.cyan : Colors.deepOrange, child: Center( child: Text(index.toString()), ), ), ), ), ), ), )); expect(nestedScrollView.currentState!.outerController.position.pixels, 0.0); expect(nestedScrollView.currentState!.innerController.position.pixels, 0.0); expect(nestedScrollView.currentState!.outerController.position.maxScrollExtent, 200.0); final Offset point = tester.getCenter(find.text('1')); // Drag slightly into overscroll in the inner position. final TestGesture gesture = await tester.startGesture(point); await gesture.moveBy(const Offset(0.0, 5.0)); await tester.pump(); expect(nestedScrollView.currentState!.outerController.position.pixels, 0.0); expect(nestedScrollView.currentState!.innerController.position.pixels, -5.0); // Move by a much larger delta than the amount of over scroll, in a very // short period of time. await gesture.moveBy(const Offset(0.0, -500.0)); await tester.pump(); // The overscrolled inner position should have closed, then passed the // correct remaining delta to the outer position, and finally any remainder // back to the inner position. expect( nestedScrollView.currentState!.outerController.position.pixels, nestedScrollView.currentState!.outerController.position.maxScrollExtent, ); expect(nestedScrollView.currentState!.innerController.position.pixels, 295.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Scroll pointer signal should not cause overscroll.', (WidgetTester tester) async { final ScrollController controller = ScrollController(); await tester.pumpWidget(buildTest(controller: controller)); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(controller.offset, 20); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -40.0))); expect(controller.offset, 0); await tester.tap(find.text('DD')); await tester.pumpAndSettle(); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 1000000.0))); expect(find.text('ddd1'), findsOneWidget); }); testWidgets('NestedScrollView basic scroll with pointer signal', (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, ); // Regression test for https://github.com/flutter/flutter/issues/55362 final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // The offset is the responsibility of innerPosition. testPointer.hover(const Offset(0, 201)); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); await tester.pump(const Duration(milliseconds: 250)); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 180.0, ); testPointer.hover(const Offset(0, 179)); await tester.sendEventToBinding(testPointer.scroll(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.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); await tester.pump(const Duration(milliseconds: 250)); expect( tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 140.0, ); }); // Related to https://github.com/flutter/flutter/issues/64266 testWidgets( 'Holding scroll and Scroll pointer signal will update ScrollDirection.forward / ScrollDirection.reverse', (WidgetTester tester) async { ScrollDirection? lastUserScrollingDirection; final ScrollController controller = ScrollController(); await tester.pumpWidget(buildTest(controller: controller)); controller.addListener(() { if (controller.position.userScrollDirection != ScrollDirection.idle) { lastUserScrollingDirection = controller.position.userScrollDirection; } }); await tester.drag(find.byType(NestedScrollView), const Offset(0.0, -20.0), touchSlopY: 0.0); expect(lastUserScrollingDirection, ScrollDirection.reverse); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 20.0))); expect(lastUserScrollingDirection, ScrollDirection.reverse); await tester.drag(find.byType(NestedScrollView), const Offset(0.0, 20.0), touchSlopY: 0.0); expect(lastUserScrollingDirection, ScrollDirection.forward); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -20.0))); expect(lastUserScrollingDirection, ScrollDirection.forward); }, ); // Regression test for https://github.com/flutter/flutter/issues/72257 testWidgets('NestedScrollView works well when rebuilding during scheduleWarmUpFrame', (WidgetTester tester) async { bool? isScrolled; final Widget myApp = MaterialApp( home: Scaffold( body: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return Focus( onFocusChange: (_) => setState( (){} ), child: NestedScrollView( headerSliverBuilder: (BuildContext context, bool boxIsScrolled) { isScrolled = boxIsScrolled; return <Widget>[ const SliverAppBar( expandedHeight: 200, title: Text('Test'), ), ]; }, body: CustomScrollView( slivers: <Widget>[ SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return const Text(''); }, childCount: 10, ), ), ], ), ), ); }, ), ), ); await tester.pumpWidget(myApp, Duration.zero, EnginePhase.build); expect(isScrolled, false); expect(tester.takeException(), isNull); }); // Regression test of https://github.com/flutter/flutter/issues/74372 testWidgets('ScrollPosition can be accessed during `_updatePosition()`', (WidgetTester tester) async { final ScrollController controller = ScrollController(); late ScrollPosition position; Widget buildFrame({ScrollPhysics? physics}) { return 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( controller: controller, physics: physics, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ Builder( builder: (BuildContext context) { position = controller.position; return const SliverAppBar( floating: true, title: Text('AA'), ); }, ), ]; }, body: Container(), ), ), ), ); } await tester.pumpWidget(buildFrame()); expect(position.pixels, 0.0); //Trigger `_updatePosition()`. await tester.pumpWidget(buildFrame(physics: const _CustomPhysics())); expect(position.pixels, 0.0); }); testWidgets("NestedScrollView doesn't crash due to precision error", (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/63825 await tester.pumpWidget(MaterialApp( home: Scaffold( body: NestedScrollView( floatHeaderSlivers: true, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) => <Widget>[ const SliverAppBar( expandedHeight: 250.0, ), ], body: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: <Widget>[ SliverPadding( padding: const EdgeInsets.all(8.0), sliver: SliverFixedExtentList( itemExtent: 48.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return ListTile( title: Text('Item $index'), ); }, childCount: 30, ), ), ), ], ), ), ), )); // Scroll to bottom await tester.fling(find.text('Item 3'), const Offset(0.0, -250.0), 10000.0); await tester.pumpAndSettle(); // Fling down for AppBar to show await tester.drag(find.text('Item 29'), const Offset(0.0, 250 - 133.7981622869321)); // Fling up to trigger ballistic activity await tester.fling(find.text('Item 25'), const Offset(0.0, -50.0), 4000.0); await tester.pumpAndSettle(); }); testWidgets('NestedScrollViewCoordinator.pointerScroll dispatches correct scroll notifications', (WidgetTester tester) async { int scrollEnded = 0; int scrollStarted = 0; bool isScrolled = false; await tester.pumpWidget(MaterialApp( home: NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification is ScrollStartNotification) { scrollStarted += 1; } else if (notification is ScrollEndNotification) { scrollEnded += 1; } return false; }, child: Scaffold( body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { isScrolled = innerBoxIsScrolled; return <Widget>[ const SliverAppBar( expandedHeight: 250.0, ), ]; }, body: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: <Widget>[ SliverPadding( padding: const EdgeInsets.all(8.0), sliver: SliverFixedExtentList( itemExtent: 48.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return ListTile( title: Text('Item $index'), ); }, childCount: 30, ), ), ), ], ), ), ), ), )); final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); // Create a hover event so that |testPointer| has a location when generating the scroll. testPointer.hover(scrollEventLocation); await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); await tester.pumpAndSettle(); expect(isScrolled, isTrue); // There should have been a notification for each nested position (2). expect(scrollStarted, 2); expect(scrollEnded, 2); }); testWidgets('SliverAppBar.medium collapses in NestedScrollView', (WidgetTester tester) async { final GlobalKey<NestedScrollViewState> nestedScrollView = GlobalKey(); const double collapsedAppBarHeight = 64; const double expandedAppBarHeight = 112; await tester.pumpWidget(MaterialApp( home: Scaffold( body: NestedScrollView( key: nestedScrollView, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar.medium( title: const Text('AppBar Title'), ), ), ]; }, body: Builder( builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[ SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => ListTile(title: Text('Item $index')), childCount: 30, ), ), ], ); }, ), ), ), )); // There are two widgets for the title. final Finder expandedTitle = find.text('AppBar Title').last; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), ); // Default, fully expanded app bar. expect(nestedScrollView.currentState?.outerController.offset, 0); expect(nestedScrollView.currentState?.innerController.offset, 0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); // Scroll the expanded app bar partially out of view. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -45.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 45.0); expect(nestedScrollView.currentState?.innerController.offset, 0.0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight - 45); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight - 45); // Scroll so that it is completely collapsed. await tester.dragFrom(point1, const Offset(0.0, -555.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 48.0); expect(nestedScrollView.currentState?.innerController.offset, 552.0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), collapsedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, 0); // Scroll back to fully expanded. await tester.dragFrom(point1, const Offset(0.0, 600.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 0); expect(nestedScrollView.currentState?.innerController.offset, 0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); }); testWidgets('SliverAppBar.large collapses in NestedScrollView', (WidgetTester tester) async { final GlobalKey<NestedScrollViewState> nestedScrollView = GlobalKey(); const double collapsedAppBarHeight = 64; const double expandedAppBarHeight = 152; await tester.pumpWidget(MaterialApp( home: Scaffold( body: NestedScrollView( key: nestedScrollView, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar.large( title: const Text('AppBar Title'), forceElevated: innerBoxIsScrolled, ), ), ]; }, body: Builder( builder: (BuildContext context) { return CustomScrollView( slivers: <Widget>[ SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), SliverFixedExtentList( itemExtent: 50.0, delegate: SliverChildBuilderDelegate( (BuildContext context, int index) => ListTile(title: Text('Item $index')), childCount: 30, ), ), ], ); }, ), ), ), )); // There are two widgets for the title. final Finder expandedTitle = find.text('AppBar Title').last; final Finder expandedTitleClip = find.ancestor( of: expandedTitle, matching: find.byType(ClipRect), ); // Default, fully expanded app bar. expect(nestedScrollView.currentState?.outerController.offset, 0); expect(nestedScrollView.currentState?.innerController.offset, 0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); // Scroll the expanded app bar partially out of view. final Offset point1 = tester.getCenter(find.text('Item 5')); await tester.dragFrom(point1, const Offset(0.0, -45.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 45.0); expect(nestedScrollView.currentState?.innerController.offset, 0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight - 45); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight - 45); // Scroll so that it is completely collapsed. await tester.dragFrom(point1, const Offset(0.0, -555.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 88.0); expect(nestedScrollView.currentState?.innerController.offset, 512.0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), collapsedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, 0); // Scroll back to fully expanded. await tester.dragFrom(point1, const Offset(0.0, 600.0)); await tester.pump(); expect(nestedScrollView.currentState?.outerController.offset, 0); expect(nestedScrollView.currentState?.innerController.offset, 0); expect(find.byType(SliverAppBar), findsOneWidget); expect(appBarHeight(tester), expandedAppBarHeight); expect(tester.getSize(expandedTitleClip).height, expandedAppBarHeight - collapsedAppBarHeight); }); } double appBarHeight(WidgetTester tester) => tester.getSize(find.byType(AppBar, skipOffstage: false)).height; 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; } class _TestLayoutExtentIsNegative extends StatelessWidget { const _TestLayoutExtentIsNegative(this.widgetCountBeforeSliverOverlapAbsorber); final int widgetCountBeforeSliverOverlapAbsorber; @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( appBar: AppBar( title: const Text('Test'), ), body: NestedScrollView( headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return <Widget>[ ...List<Widget>.generate(widgetCountBeforeSliverOverlapAbsorber, (_) { return SliverToBoxAdapter( child: Container( color: Colors.red, height: 200, margin:const EdgeInsets.all(20), ), ); }), SliverOverlapAbsorber( handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: SliverAppBar( pinned: true, forceElevated: innerBoxIsScrolled, backgroundColor: Colors.blue[300], title: const SizedBox( height: 50, child: Center( child: Text('Sticky Header'), ), ), ), ), ]; }, body: Container( height: 2000, margin: const EdgeInsets.only(top: 50), child: ListView( children: List<Widget>.generate(3, (_) { return Container( color: Colors.green[200], height: 200, margin: const EdgeInsets.all(20), ); }), ), ), ), ), ); } }