Support floating the header slivers of a NestedScrollView (#59187)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -64,7 +64,7 @@ typedef NestedScrollViewHeaderSliversBuilder = List<Widget> 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<Widget> 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 <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((String name) => Tab(text: name)).toList(),
|
||||
/// Widget build(BuildContext context) {
|
||||
/// final List<String> _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 <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((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<String>(name),
|
||||
/// 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,
|
||||
/// ];
|
||||
/// },
|
||||
/// 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<String>(name),
|
||||
/// 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(),
|
||||
/// ],
|
||||
/// );
|
||||
/// },
|
||||
/// ),
|
||||
/// );
|
||||
/// }).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}
|
||||
///
|
||||
/// ### 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 {
|
||||
/// 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<NestedScrollView> {
|
||||
_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<double> overscrolls = <double>[];
|
||||
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<double> overscrolls = <double>[];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: <RenderSliver>[
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 <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 {
|
||||
|
||||
Reference in New Issue
Block a user