From 64f42c0ea8a8bc0ed2353e2bb7f93456102d037e Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Fri, 12 Jun 2020 09:15:02 -0700 Subject: [PATCH] Support floating the header slivers of a NestedScrollView (#59187) --- .../rendering/sliver_persistent_header.dart | 2 +- .../lib/src/widgets/nested_scroll_view.dart | 444 ++++++++++---- .../sliver_persistent_header_test.dart | 30 + .../test/widgets/nested_scroll_view_test.dart | 566 ++++++++++++++++++ 4 files changed, 923 insertions(+), 119 deletions(-) diff --git a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart index 11853de4f7..29fdc01f08 100644 --- a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart @@ -644,7 +644,7 @@ abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFl paintExtent: clampedPaintExtent, layoutExtent: layoutExtent.clamp(0.0, clampedPaintExtent) as double, maxPaintExtent: maxExtent + stretchOffset, - maxScrollObstructionExtent: maxExtent, + maxScrollObstructionExtent: minExtent, hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. ); return 0.0; diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index ba444c304e..f63a578ed3 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -64,7 +64,7 @@ typedef NestedScrollViewHeaderSliversBuilder = List Function(BuildContex /// (those inside the [TabBarView], hooking them together so that they appear, /// to the user, as one coherent scroll view. /// -/// {@tool snippet} +/// {@tool sample --template=stateless_widget_scaffold} /// /// This example shows a [NestedScrollView] whose header is the combination of a /// [TabBar] in a [SliverAppBar] and whose body is a [TabBarView]. It uses a @@ -74,113 +74,292 @@ typedef NestedScrollViewHeaderSliversBuilder = List Function(BuildContex /// [PageStorageKey]s are used to remember the scroll position of each tab's /// list. /// -/// In the example below, `_tabs` is a list of strings, one for each tab, giving -/// the tab labels. In a real application, it would be replaced by the actual -/// data model being represented. -/// /// ```dart -/// DefaultTabController( -/// length: _tabs.length, // This is the number of tabs. -/// child: NestedScrollView( -/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { -/// // These are the slivers that show up in the "outer" scroll view. -/// return [ -/// SliverOverlapAbsorber( -/// // This widget takes the overlapping behavior of the SliverAppBar, -/// // and redirects it to the SliverOverlapInjector below. If it is -/// // missing, then it is possible for the nested "inner" scroll view -/// // below to end up under the SliverAppBar even when the inner -/// // scroll view thinks it has not been scrolled. -/// // This is not necessary if the "headerSliverBuilder" only builds -/// // widgets that do not overlap the next sliver. -/// 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((String name) => Tab(text: name)).toList(), +/// Widget build(BuildContext context) { +/// final List _tabs = ['Tab 1', 'Tab 2']; +/// return DefaultTabController( +/// length: _tabs.length, // This is the number of tabs. +/// child: NestedScrollView( +/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { +/// // These are the slivers that show up in the "outer" scroll view. +/// return [ +/// SliverOverlapAbsorber( +/// // This widget takes the overlapping behavior of the SliverAppBar, +/// // and redirects it to the SliverOverlapInjector below. If it is +/// // missing, then it is possible for the nested "inner" scroll view +/// // below to end up under the SliverAppBar even when the inner +/// // scroll view thinks it has not been scrolled. +/// // This is not necessary if the "headerSliverBuilder" only builds +/// // widgets that do not overlap the next sliver. +/// 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((String name) => Tab(text: name)).toList(), +/// ), /// ), /// ), -/// ), -/// ]; -/// }, -/// body: TabBarView( -/// // These are the contents of the tab views, below the tabs. -/// children: _tabs.map((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(name), -/// slivers: [ -/// 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, +/// ]; +/// }, +/// body: TabBarView( +/// // These are the contents of the tab views, below the tabs. +/// children: _tabs.map((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(name), +/// slivers: [ +/// 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(), +/// ], +/// ); +/// }, +/// ), +/// ); +/// }).toList(), +/// ), /// ), -/// ), -/// ) +/// ); +/// } /// ``` /// {@end-tool} +/// +/// ## [SliverAppBar]s with [NestedScrollView]s +/// +/// Using a [SliverAppBar] in the outer scroll view, or [headerSliverBuilder], +/// of a [NestedScrollView] may require special configurations in order to work +/// as it would if the outer and inner were one single scroll view, like a +/// [CustomScrollView]. +/// +/// ### Pinned [SliverAppBar]s +/// +/// A pinned [SliverAppBar] works in a [NestedScrollView] exactly as it would in +/// another scroll view, like [CustomScrollView]. When using +/// [SliverAppBar.pinned], the app bar remains visible at the top of the scroll +/// view. The app bar can still expand and contract as the user scrolls, but it +/// will remain visible rather than being scrolled out of view. +/// +/// This works naturally in a [NestedScroll] view, as the pinned [SliverAppBar] +/// is not expected to move in or out of the visible portion of the viewport. +/// As the inner or outer [Scrollable]s are moved, the app bar persists as +/// expected. +/// +/// If the app bar is floating, pinned, and using an expanded height, follow the +/// floating convention laid out below. +/// +/// ### Floating [SliverAppBar]s +/// +/// When placed in the outer scrollable, or the [headerSliverBuilder], +/// a [SliverAppBar] that floats, using [SliverAppBar.floating] will not be +/// triggered to float over the inner scroll view, or [body], automatically. +/// +/// This is because a floating app bar uses the scroll offset of its own +/// [Scrollable] to dictate the floating action. Being two separate inner and +/// outer [Scrollable]s, a [SliverAppBar] in the outer header is not aware of +/// changes in the scroll offset of the inner body. +/// +/// In order to float the outer, use [NestedScrollView.floatHeaderSlivers]. When +/// set to true, the nested scrolling coordinator will prioritize floating in +/// the header slivers before applying the remaining drag to the body. +/// +/// Furthermore, the `floatHeaderSlivers` flag should also be used when using an +/// app bar that is floating, pinned, and has an expanded height. In this +/// configuration, the flexible space of the app bar will open and collapse, +/// while the primary portion of the app bar remains pinned. +/// +/// {@tool sample --template=stateless_widget_material} +/// +/// This simple example shows a [NestedScrollView] whose header contains a +/// floating [SliverAppBar]. By using the [floatHeaderSlivers] property, the +/// floating behavior is coordinated between the outer and inner [Scrollable]s, +/// so it behaves as it would in a single scrollable. +/// +/// ```dart +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: NestedScrollView( +/// // Setting floatHeaderSlivers to true is required in order to float +/// // the outer slivers over the inner scrollable. +/// floatHeaderSlivers: true, +/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { +/// return [ +/// SliverAppBar( +/// title: const Text('Floating Nested SliverAppBar'), +/// floating: true, +/// expandedHeight: 200.0, +/// forceElevated: innerBoxIsScrolled, +/// ), +/// ]; +/// }, +/// body: ListView.builder( +/// padding: const EdgeInsets.all(8), +/// itemCount: 30, +/// itemBuilder: (BuildContext context, int index) { +/// return Container( +/// height: 50, +/// child: Center(child: Text('Item $index')), +/// ); +/// } +/// ) +/// ) +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// ### Snapping [SliverAppBar]s +/// +/// Floating [SliverAppBars] also have the option to perform a snapping animation. +/// If [SliverAppBar.snap] is true, then a scroll that exposes the floating app +/// bar will trigger an animation that slides the entire app bar into view. +/// Similarly if a scroll dismisses the app bar, the animation will slide the +/// app bar completely out of view. +/// +/// It is possible with a [NestedScrollView] to perform just the snapping +/// animation without floating the app bar in and out. By not using the +/// [NestedScrollView.floatHeaderSlivers], the app bar will snap in and out +/// without floating. +/// +/// The [SliverAppBar.snap] animation should be used in conjunction with the +/// [SliverOverlapAbsorber] and [SliverOverlapInjector] widgets when +/// implemented in a [NestedScrollView]. These widgets take any overlapping +/// behavior of the [SliverAppBar] in the header and redirect it to the +/// [SliverOverlapInjector] in the body. 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. +/// +/// {@tool sample --template=stateless_widget_material} +/// +/// This simple example shows a [NestedScrollView] whose header contains a +/// snapping, floating [SliverAppBar]. _Without_ setting any additional flags, +/// e.g [NestedScrollView.floatHeaderSlivers] and [SliverAppBar.nestedSnap], the +/// [SliverAppBar] will animate in and out without floating. The +/// [SliverOverlapAbsorber] and [SliverOverlapInjector] maintain the proper +/// alignment between the two separate scroll views. +/// +/// ```dart +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: NestedScrollView( +/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { +/// return [ +/// SliverOverlapAbsorber( +/// handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), +/// sliver: SliverAppBar( +/// title: const Text('Snapping Nested SliverAppBar'), +/// floating: true, +/// snap: true, +/// expandedHeight: 200.0, +/// forceElevated: innerBoxIsScrolled, +/// ), +/// ) +/// ]; +/// }, +/// body: Builder( +/// 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. +/// slivers: [ +/// SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), +/// SliverFixedExtentList( +/// itemExtent: 48.0, +/// delegate: SliverChildBuilderDelegate( +/// (BuildContext context, int index) => ListTile(title: Text('Item $index')), +/// childCount: 30, +/// ), +/// ), +/// ], +/// ); +/// } +/// ) +/// ) +/// ); +/// } +/// ``` +/// {@end-tool} +/// +/// ### Snapping and Floating [SliverAppBar]s +/// +// See https://github.com/flutter/flutter/issues/59189 +/// Currently, [NestedScrollView] does not support simultaneously floating and +/// snapping the outer scrollable, e.g. when using [SliverAppBar.floating] & +/// [SliverAppBar.snap] at the same time. +/// +/// ### Stretching [SliverAppBar]s +/// +// TODO(Piinks): Support stretching, https://github.com/flutter/flutter/issues/54059 +/// Currently, [NestedScrollView] does not support stretching the outer +/// scrollable, e.g. when using [SliverAppBar.stretch]. +/// +/// See also: +/// +/// * [SliverAppBar], for examples on different configurations like floating, +/// pinned and snap behaviors. +/// * [SliverOverlapAbsorber], a sliver that wraps another, forcing its layout +/// extent to be treated as overlap. +/// * [SliverOverlapInjector], a sliver that has a sliver geometry based on +/// the values stored in a [SliverOverlapAbsorberHandle]. class NestedScrollView extends StatefulWidget { /// Creates a nested scroll view. /// @@ -195,10 +374,12 @@ class NestedScrollView extends StatefulWidget { @required this.headerSliverBuilder, @required this.body, this.dragStartBehavior = DragStartBehavior.start, + this.floatHeaderSlivers = false, }) : assert(scrollDirection != null), assert(reverse != null), assert(headerSliverBuilder != null), assert(body != null), + assert(floatHeaderSlivers != null), super(key: key); /// An object that can be used to control the position to which the outer @@ -262,6 +443,13 @@ class NestedScrollView extends StatefulWidget { /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; + /// Whether or not the [NestedScrollView]'s coordinator should prioritize the + /// outer scrollable over the inner when scrolling back. + /// + /// This is useful for an outer scrollable containing a [SliverAppBar] that + /// is expected to float. This cannot be null. + final bool floatHeaderSlivers; + /// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor /// [NestedScrollView]. /// @@ -378,6 +566,7 @@ class NestedScrollViewState extends State { _coordinator = _NestedScrollCoordinator( this, widget.controller, _handleHasScrolledBodyChanged, + widget.floatHeaderSlivers, ); } @@ -549,7 +738,12 @@ class _NestedScrollMetrics extends FixedScrollMetrics { typedef _NestedScrollActivityGetter = ScrollActivity Function(_NestedScrollPosition position); class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { - _NestedScrollCoordinator(this._state, this._parent, this._onHasScrolledBodyChanged) { + _NestedScrollCoordinator( + this._state, + this._parent, + this._onHasScrolledBodyChanged, + this._floatHeaderSlivers, + ) { final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0; _outerController = _NestedScrollController( this, @@ -566,6 +760,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont final NestedScrollViewState _state; ScrollController _parent; final VoidCallback _onHasScrolledBodyChanged; + final bool _floatHeaderSlivers; _NestedScrollController _outerController; _NestedScrollController _innerController; @@ -935,23 +1130,36 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont } } else { // Dragging "down" - delta is positive - // Prioritize the inner views, so that the inner content will move before - // the app bar grows - double outerDelta = 0.0; // it will go positive if it changes - final List overscrolls = []; - final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); - for (final _NestedScrollPosition position in innerPositions) { - final double overscroll = position.applyClampedDragUpdate(delta); - outerDelta = math.max(outerDelta, overscroll); - overscrolls.add(overscroll); - } - if (outerDelta != 0.0) - outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta); - // now deal with any overscroll - for (int i = 0; i < innerPositions.length; ++i) { - final double remainingDelta = overscrolls[i] - outerDelta; - if (remainingDelta > 0.0) - innerPositions[i].applyFullDragUpdate(remainingDelta); + double innerDelta = delta; + // Apply delta to the outer header first if it is configured to float. + if (_floatHeaderSlivers) + innerDelta = _outerPosition.applyClampedDragUpdate(delta); + + if (innerDelta != 0.0) { + // Apply the innerDelta, if we have not floated in the outer scrollable, + // any leftover delta after this will be passed on to the outer + // scrollable by the outerDelta. + double outerDelta = 0.0; // it will go positive if it changes + final List overscrolls = []; + final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); + for (final _NestedScrollPosition position in innerPositions) { + final double overscroll = position.applyClampedDragUpdate(innerDelta); + outerDelta = math.max(outerDelta, overscroll); + overscrolls.add(overscroll); + } + if (outerDelta != 0.0) + outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta); + + // Now deal with any overscroll + // TODO(Piinks): Configure which scrollable receives overscroll to + // support stretching app bars. createOuterBallisticScrollActivity will + // need to be updated as it currently assumes the outer position will + // never overscroll, https://github.com/flutter/flutter/issues/54059 + for (int i = 0; i < innerPositions.length; ++i) { + final double remainingDelta = overscrolls[i] - outerDelta; + if (remainingDelta > 0.0) + innerPositions[i].applyFullDragUpdate(remainingDelta); + } } } } diff --git a/packages/flutter/test/rendering/sliver_persistent_header_test.dart b/packages/flutter/test/rendering/sliver_persistent_header_test.dart index 08264f4832..ab18603c35 100644 --- a/packages/flutter/test/rendering/sliver_persistent_header_test.dart +++ b/packages/flutter/test/rendering/sliver_persistent_header_test.dart @@ -26,6 +26,24 @@ void main() { expect(header.geometry.maxScrollObstructionExtent, 0); }); + + test('RenderSliverFloatingPinnedPersistentHeader maxScrollObstructionExtent is minExtent', () { + final TestRenderSliverFloatingPinnedPersistentHeader header = TestRenderSliverFloatingPinnedPersistentHeader( + child: RenderSizedBox(const Size(400.0, 100.0) + )); + final RenderViewport root = RenderViewport( + axisDirection: AxisDirection.down, + crossAxisDirection: AxisDirection.right, + offset: ViewportOffset.zero(), + cacheExtent: 0, + children: [ + header, + ], + ); + layout(root); + + expect(header.geometry.maxScrollObstructionExtent, 100.0); + }); } class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersistentHeader { @@ -39,3 +57,15 @@ class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersi @override double get minExtent => 100; } + +class TestRenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPinnedPersistentHeader { + TestRenderSliverFloatingPinnedPersistentHeader({ + RenderBox child, + }) : super(child: child); + + @override + double get maxExtent => 200; + + @override + double get minExtent => 100; +} diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index cde607c43e..95435eab7f 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -1200,6 +1200,572 @@ void main() { 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 [ + 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: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + SliverFixedExtentList( + itemExtent: 50.0, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => ListTile(title: Text('Item $index')), + childCount: 30, + ) + ), + ], + ); + } + ) + ) + ) + ); + } + + double verifyGeometry({ + GlobalKey key, + double paintExtent, + bool extentGreaterThan = false, + bool extentLessThan = false, + bool visible, + }) { + final RenderSliver target = key.currentContext.findRenderObject() as RenderSliver; + final SliverGeometry geometry = target.geometry; + expect(target.parent, isA()); + 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(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(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(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(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(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(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 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(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 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(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(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(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(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(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(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(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(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(find.byType(AppBar)).size.height, + 200.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 200.0, visible: true); + }); + }); } class TestHeader extends SliverPersistentHeaderDelegate {