Unverified Commit 64f42c0e authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Support floating the header slivers of a NestedScrollView (#59187)

parent e96b13c7
...@@ -644,7 +644,7 @@ abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFl ...@@ -644,7 +644,7 @@ abstract class RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFl
paintExtent: clampedPaintExtent, paintExtent: clampedPaintExtent,
layoutExtent: layoutExtent.clamp(0.0, clampedPaintExtent) as double, layoutExtent: layoutExtent.clamp(0.0, clampedPaintExtent) as double,
maxPaintExtent: maxExtent + stretchOffset, maxPaintExtent: maxExtent + stretchOffset,
maxScrollObstructionExtent: maxExtent, maxScrollObstructionExtent: minExtent,
hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
); );
return 0.0; return 0.0;
......
...@@ -64,7 +64,7 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex ...@@ -64,7 +64,7 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex
/// (those inside the [TabBarView], hooking them together so that they appear, /// (those inside the [TabBarView], hooking them together so that they appear,
/// to the user, as one coherent scroll view. /// 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 /// 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 /// [TabBar] in a [SliverAppBar] and whose body is a [TabBarView]. It uses a
...@@ -74,12 +74,10 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex ...@@ -74,12 +74,10 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex
/// [PageStorageKey]s are used to remember the scroll position of each tab's /// [PageStorageKey]s are used to remember the scroll position of each tab's
/// list. /// 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 /// ```dart
/// DefaultTabController( /// Widget build(BuildContext context) {
/// final List<String> _tabs = ['Tab 1', 'Tab 2'];
/// return DefaultTabController(
/// length: _tabs.length, // This is the number of tabs. /// length: _tabs.length, // This is the number of tabs.
/// child: NestedScrollView( /// child: NestedScrollView(
/// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { /// headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
...@@ -178,9 +176,190 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex ...@@ -178,9 +176,190 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> Function(BuildContex
/// }).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 <Widget>[
/// 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} /// {@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 <Widget>[
/// 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: <Widget>[
/// 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 { class NestedScrollView extends StatefulWidget {
/// Creates a nested scroll view. /// Creates a nested scroll view.
/// ///
...@@ -195,10 +374,12 @@ class NestedScrollView extends StatefulWidget { ...@@ -195,10 +374,12 @@ class NestedScrollView extends StatefulWidget {
@required this.headerSliverBuilder, @required this.headerSliverBuilder,
@required this.body, @required this.body,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.floatHeaderSlivers = false,
}) : assert(scrollDirection != null), }) : assert(scrollDirection != null),
assert(reverse != null), assert(reverse != null),
assert(headerSliverBuilder != null), assert(headerSliverBuilder != null),
assert(body != null), assert(body != null),
assert(floatHeaderSlivers != null),
super(key: key); super(key: key);
/// An object that can be used to control the position to which the outer /// An object that can be used to control the position to which the outer
...@@ -262,6 +443,13 @@ class NestedScrollView extends StatefulWidget { ...@@ -262,6 +443,13 @@ class NestedScrollView extends StatefulWidget {
/// {@macro flutter.widgets.scrollable.dragStartBehavior} /// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior 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 /// Returns the [SliverOverlapAbsorberHandle] of the nearest ancestor
/// [NestedScrollView]. /// [NestedScrollView].
/// ///
...@@ -378,6 +566,7 @@ class NestedScrollViewState extends State<NestedScrollView> { ...@@ -378,6 +566,7 @@ class NestedScrollViewState extends State<NestedScrollView> {
_coordinator = _NestedScrollCoordinator( _coordinator = _NestedScrollCoordinator(
this, widget.controller, this, widget.controller,
_handleHasScrolledBodyChanged, _handleHasScrolledBodyChanged,
widget.floatHeaderSlivers,
); );
} }
...@@ -549,7 +738,12 @@ class _NestedScrollMetrics extends FixedScrollMetrics { ...@@ -549,7 +738,12 @@ class _NestedScrollMetrics extends FixedScrollMetrics {
typedef _NestedScrollActivityGetter = ScrollActivity Function(_NestedScrollPosition position); typedef _NestedScrollActivityGetter = ScrollActivity Function(_NestedScrollPosition position);
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController { 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; final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
_outerController = _NestedScrollController( _outerController = _NestedScrollController(
this, this,
...@@ -566,6 +760,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -566,6 +760,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
final NestedScrollViewState _state; final NestedScrollViewState _state;
ScrollController _parent; ScrollController _parent;
final VoidCallback _onHasScrolledBodyChanged; final VoidCallback _onHasScrolledBodyChanged;
final bool _floatHeaderSlivers;
_NestedScrollController _outerController; _NestedScrollController _outerController;
_NestedScrollController _innerController; _NestedScrollController _innerController;
...@@ -935,19 +1130,31 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -935,19 +1130,31 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
} }
} else { } else {
// Dragging "down" - delta is positive // Dragging "down" - delta is positive
// Prioritize the inner views, so that the inner content will move before double innerDelta = delta;
// the app bar grows // 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 double outerDelta = 0.0; // it will go positive if it changes
final List<double> overscrolls = <double>[]; final List<double> overscrolls = <double>[];
final List<_NestedScrollPosition> innerPositions = _innerPositions.toList(); final List<_NestedScrollPosition> innerPositions = _innerPositions.toList();
for (final _NestedScrollPosition position in innerPositions) { for (final _NestedScrollPosition position in innerPositions) {
final double overscroll = position.applyClampedDragUpdate(delta); final double overscroll = position.applyClampedDragUpdate(innerDelta);
outerDelta = math.max(outerDelta, overscroll); outerDelta = math.max(outerDelta, overscroll);
overscrolls.add(overscroll); overscrolls.add(overscroll);
} }
if (outerDelta != 0.0) if (outerDelta != 0.0)
outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta); outerDelta -= _outerPosition.applyClampedDragUpdate(outerDelta);
// now deal with any overscroll
// 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) { for (int i = 0; i < innerPositions.length; ++i) {
final double remainingDelta = overscrolls[i] - outerDelta; final double remainingDelta = overscrolls[i] - outerDelta;
if (remainingDelta > 0.0) if (remainingDelta > 0.0)
...@@ -955,6 +1162,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont ...@@ -955,6 +1162,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
} }
} }
} }
}
void setParent(ScrollController value) { void setParent(ScrollController value) {
_parent = value; _parent = value;
......
...@@ -26,6 +26,24 @@ void main() { ...@@ -26,6 +26,24 @@ void main() {
expect(header.geometry.maxScrollObstructionExtent, 0); 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: <RenderSliver>[
header,
],
);
layout(root);
expect(header.geometry.maxScrollObstructionExtent, 100.0);
});
} }
class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersistentHeader { class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersistentHeader {
...@@ -39,3 +57,15 @@ class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersi ...@@ -39,3 +57,15 @@ class TestRenderSliverFloatingPersistentHeader extends RenderSliverFloatingPersi
@override @override
double get minExtent => 100; double get minExtent => 100;
} }
class TestRenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPinnedPersistentHeader {
TestRenderSliverFloatingPinnedPersistentHeader({
RenderBox child,
}) : super(child: child);
@override
double get maxExtent => 200;
@override
double get minExtent => 100;
}
...@@ -1200,6 +1200,572 @@ void main() { ...@@ -1200,6 +1200,572 @@ void main() {
await tester.pumpWidget(const _TestLayoutExtentIsNegative(1)); await tester.pumpWidget(const _TestLayoutExtentIsNegative(1));
await tester.pumpWidget(const _TestLayoutExtentIsNegative(10)); 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({
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<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('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);
});
});
} }
class TestHeader extends SliverPersistentHeaderDelegate { class TestHeader extends SliverPersistentHeaderDelegate {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment