SliverResizingHeader (#143325)

A sliver that is pinned to the start of its `CustomScrollView` and reacts to scrolling by resizing between the intrinsic sizes of its min and max extent prototypes.

The minimum and maximum sizes of this sliver are defined by `minExtentPrototype` and `maxExtentPrototype`, a pair of widgets that are laid out once. You can use `SizedBox` widgets to define the size limits.

This sliver is preferable to the general purpose `SliverPersistentHeader` for its relatively narrow use case because there's no need to create a `SliverPersistentHeaderDelegate` or to predict the header's minimum or maximum size.

The sample shows how this sliver's two extent prototype properties can be used to create a resizing header whose minimum and maximum sizes match small and large configurations of the same header widget.

https://github.com/flutter/flutter/assets/1377460/fa7ced98-9d92-4d13-b093-50392118c213

Related sliver utility PRs: https://github.com/flutter/flutter/pull/143538, https://github.com/flutter/flutter/pull/143196, https://github.com/flutter/flutter/pull/127340.
This commit is contained in:
Hans Muller
2024-06-06 15:35:12 -07:00
committed by GitHub
parent ea93790479
commit 7e3e30929a
5 changed files with 756 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
void main() {
runApp(const SliverResizingHeaderApp());
}
class SliverResizingHeaderApp extends StatelessWidget {
const SliverResizingHeaderApp({ super.key });
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: ResizingHeaderExample(),
);
}
}
class ResizingHeaderExample extends StatefulWidget {
const ResizingHeaderExample({ super.key });
@override
State<ResizingHeaderExample> createState() => _ResizingHeaderExampleState();
}
class _ResizingHeaderExampleState extends State<ResizingHeaderExample> {
late final ScrollController scrollController;
@override
void initState() {
super.initState();
scrollController = ScrollController();
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(4),
child: Scrollbar(
controller: scrollController,
child: CustomScrollView(
controller: scrollController,
slivers: const <Widget>[
SliverResizingHeader(
minExtentPrototype: ListHeader(
text: 'One',
),
maxExtentPrototype: ListHeader(
text: 'One\nTwo\nThree'
),
child: ListHeader(
text: 'SliverResizingHeader\nWith Two Optional\nLines of Text',
),
),
ItemList(),
],
),
),
),
),
);
}
}
// A widget that displays its text within a thick rounded rectangle border
class ListHeader extends StatelessWidget {
const ListHeader({
super.key,
required this.text,
});
final String text;
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
return Container(
color: colorScheme.background,
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Material(
color: colorScheme.primaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
width: 7,
color: colorScheme.outline,
),
),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
text,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium!.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
),
),
);
}
}
// A placeholder SliverList of 50 items.
class ItemList extends StatelessWidget {
const ItemList({
super.key,
this.itemCount = 50,
});
final int itemCount;
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return Card(
color: colorScheme.onSecondary,
child: ListTile(
textColor: colorScheme.secondary,
title: Text('Item $index'),
),
);
},
childCount: itemCount,
),
);
}
}

View File

@@ -0,0 +1,26 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/sliver/sliver_resizing_header.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('SliverResizingHeader example', (WidgetTester tester) async {
await tester.pumpWidget(
const example.SliverResizingHeaderApp(),
);
final Finder headerMaterial = find.text('SliverResizingHeader\nWith Two Optional\nLines of Text');
final double initialHeight = tester.getSize(headerMaterial).height;
await tester.drag(find.byType(CustomScrollView), const Offset(0, -200));
await tester.pumpAndSettle();
expect(tester.getSize(headerMaterial).height, lessThan(initialHeight / 2));
await tester.drag(find.byType(CustomScrollView), const Offset(0, 200));
await tester.pumpAndSettle();
expect(tester.getSize(headerMaterial).height, initialHeight);
});
}

View File

@@ -0,0 +1,232 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'focus_scope.dart';
import 'framework.dart';
import 'slotted_render_object_widget.dart';
/// A sliver that is pinned to the start of its [CustomScrollView] and
/// reacts to scrolling by resizing between the intrinsic sizes of its
/// min and max extent prototypes.
///
/// The minimum and maximum sizes of this sliver are defined by [minExtentPrototype]
/// and [maxExtentPrototype], a pair of widgets that are laid out once. You can
/// use [SizedBox] widgets to define the size limits.
///
/// If the [minExtentPrototype] is null, then the default minimum extent is 0. If
/// [maxExtentPrototype] is null then the default maximum extent is based on the child's
/// intrisic size.
///
/// This sliver is preferable to the general purpose [SliverPersistentHeader]
/// for its relatively narrow use case because there's no need to create a
/// [SliverPersistentHeaderDelegate] or to predict the header's minimum or
/// maximum size.
///
/// {@tool dartpad}
/// This sample shows how this sliver's two extent prototype properties can be used to
/// create a resizing header whose minimum and maximum sizes match small and large
/// configurations of the same header widget.
///
/// ** See code in examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart **
/// {@end-tool}
// TODO(hansmuller): add See also links to PersistentHeaderSliver, SliverFloatingHeader, etc
class SliverResizingHeader extends StatelessWidget {
/// Create a pinned header sliver that reacts to scrolling by resizing between
/// the intrinsic sizes of the min and max extent prototypes.
const SliverResizingHeader({
super.key,
this.minExtentPrototype,
this.maxExtentPrototype,
this.child,
});
/// Laid out once to define the minimum size of this sliver along the
/// [CustomScrollView.scrollDirection] axis.
///
/// If null, the minimum size of the sliver is 0.
///
/// This widget is never made visible.
final Widget? minExtentPrototype;
/// Laid out once to define the maximum size of this sliver along the
/// [CustomScrollView.scrollDirection] axis.
///
/// If null, the maximum extent of the sliver is based on the child's
/// intrinsic size.
///
/// This widget is never made visible.
final Widget? maxExtentPrototype;
/// The widget contained by this sliver.
final Widget? child;
Widget? _excludeFocus(Widget? extentPrototype) {
return extentPrototype != null ? ExcludeFocus(child: extentPrototype) : null;
}
@override
Widget build(BuildContext context) {
return _SliverResizingHeader(
minExtentPrototype: _excludeFocus(minExtentPrototype),
maxExtentPrototype: _excludeFocus(maxExtentPrototype),
child: child ?? const SizedBox.shrink(),
);
}
}
enum _Slot {
minExtent,
maxExtent,
child,
}
class _SliverResizingHeader extends SlottedMultiChildRenderObjectWidget<_Slot, RenderBox> {
const _SliverResizingHeader({
this.minExtentPrototype,
this.maxExtentPrototype,
required this.child,
});
final Widget? minExtentPrototype;
final Widget? maxExtentPrototype;
final Widget child;
@override
Iterable<_Slot> get slots => _Slot.values;
@override
Widget? childForSlot(_Slot slot) {
return switch (slot) {
_Slot.minExtent => minExtentPrototype,
_Slot.maxExtent => maxExtentPrototype,
_Slot.child => child,
};
}
@override
_RenderSliverResizingHeader createRenderObject(BuildContext context) {
return _RenderSliverResizingHeader();
}
}
class _RenderSliverResizingHeader extends RenderSliver with SlottedContainerRenderObjectMixin<_Slot, RenderBox>, RenderSliverHelpers {
RenderBox? get minExtentPrototype => childForSlot(_Slot.minExtent);
RenderBox? get maxExtentPrototype => childForSlot(_Slot.maxExtent);
RenderBox? get child => childForSlot(_Slot.child);
@override
Iterable<RenderBox> get children {
return <RenderBox>[
if (minExtentPrototype != null) minExtentPrototype!,
if (maxExtentPrototype != null) maxExtentPrototype!,
if (child != null) child!,
];
}
double boxExtent(RenderBox box) {
assert(box.hasSize);
return switch (constraints.axis) {
Axis.vertical => box.size.height,
Axis.horizontal => box.size.width,
};
}
double get childExtent => child == null ? 0 : boxExtent(child!);
@override
void setupParentData(RenderObject child) {
if (child.parentData is! SliverPhysicalParentData) {
child.parentData = SliverPhysicalParentData();
}
}
@protected
void setChildParentData(RenderObject child, SliverConstraints constraints, SliverGeometry geometry) {
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
final AxisDirection direction = applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection);
childParentData.paintOffset = switch (direction) {
AxisDirection.up => Offset(0.0, -(geometry.scrollExtent - (geometry.paintExtent + constraints.scrollOffset))),
AxisDirection.right => Offset(-constraints.scrollOffset, 0.0),
AxisDirection.down => Offset(0.0, -constraints.scrollOffset),
AxisDirection.left => Offset(-(geometry.scrollExtent - (geometry.paintExtent + constraints.scrollOffset)), 0.0),
};
}
@override
double childMainAxisPosition(covariant RenderObject child) => 0;
@override
void performLayout() {
final SliverConstraints constraints = this.constraints;
final BoxConstraints prototypeBoxConstraints = constraints.asBoxConstraints();
double minExtent = 0;
if (minExtentPrototype != null) {
minExtentPrototype!.layout(prototypeBoxConstraints, parentUsesSize: true);
minExtent = boxExtent(minExtentPrototype!);
}
late final double maxExtent;
if (maxExtentPrototype != null) {
maxExtentPrototype!.layout(prototypeBoxConstraints, parentUsesSize: true);
maxExtent = boxExtent(maxExtentPrototype!);
} else {
final Size childSize = child!.getDryLayout(prototypeBoxConstraints);
maxExtent = switch (constraints.axis) {
Axis.vertical => childSize.height,
Axis.horizontal => childSize.width,
};
}
final double scrollOffset = constraints.scrollOffset;
final double shrinkOffset = math.min(scrollOffset, maxExtent);
final BoxConstraints boxConstraints = constraints.asBoxConstraints(
minExtent: minExtent,
maxExtent: math.max(minExtent, maxExtent - shrinkOffset),
);
child?.layout(boxConstraints, parentUsesSize: true);
final double remainingPaintExtent = constraints.remainingPaintExtent;
final double layoutExtent = math.min(childExtent, maxExtent - scrollOffset);
geometry = SliverGeometry(
scrollExtent: maxExtent,
paintOrigin: constraints.overlap,
paintExtent: math.min(childExtent, remainingPaintExtent),
layoutExtent: clampDouble(layoutExtent, 0, remainingPaintExtent),
maxPaintExtent: childExtent,
maxScrollObstructionExtent: childExtent,
cacheExtent: calculateCacheOffset(constraints, from: 0.0, to: childExtent),
hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
);
}
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
final SliverPhysicalParentData childParentData = child.parentData! as SliverPhysicalParentData;
childParentData.applyPaintTransform(transform);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
final SliverPhysicalParentData childParentData = child!.parentData! as SliverPhysicalParentData;
context.paintChild(child!, offset + childParentData.paintOffset);
}
}
@override
bool hitTestChildren(SliverHitTestResult result, { required double mainAxisPosition, required double crossAxisPosition }) {
assert(geometry!.hitTestExtent > 0.0);
if (child != null) {
return hitTestBoxChild(BoxHitTestResult.wrap(result), child!, mainAxisPosition: mainAxisPosition, crossAxisPosition: crossAxisPosition);
}
return false;
}
}

View File

@@ -137,6 +137,7 @@ export 'src/widgets/sliver_fill.dart';
export 'src/widgets/sliver_layout_builder.dart';
export 'src/widgets/sliver_persistent_header.dart';
export 'src/widgets/sliver_prototype_extent_list.dart';
export 'src/widgets/sliver_resizing_header.dart';
export 'src/widgets/slotted_render_object_widget.dart';
export 'src/widgets/snapshot_widget.dart';
export 'src/widgets/spacer.dart';

View File

@@ -0,0 +1,352 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('SliverResizingHeader basics', (WidgetTester tester) async {
Widget buildFrame({ required Axis axis, required bool reverse }) {
final (Widget minPrototype, Widget maxPrototype) = switch (axis) {
Axis.vertical => (const SizedBox(height: 100), const SizedBox(height: 300)),
Axis.horizontal => (const SizedBox(width: 100), const SizedBox(width: 300)),
};
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
scrollDirection: axis,
reverse: reverse,
slivers: <Widget>[
SliverResizingHeader(
minExtentPrototype: minPrototype,
maxExtentPrototype: maxPrototype,
child: const SizedBox.expand(child: Text('header')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Text('item $index'),
childCount: 100,
),
),
],
),
),
);
}
Rect getHeaderRect() => tester.getRect(find.text('header'));
Rect getItemRect(int index) => tester.getRect(find.text('item $index'));
// axis: Axis.vertical, reverse: false
{
await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: false));
await tester.pumpAndSettle();
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
// The test viewport is width=800 x height=600
// The height=300 header is at the top of the scroll view and all items are the same height.
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 300);
// First and last visible items
final double itemHeight = getItemRect(0).height;
final int visibleItemCount = 300 ~/ itemHeight; // 300 = viewport height - header height
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
// Scrolling up and down leaves the header at the top but changes its height
// between the heights of the min and max extent prototypes.
position.moveTo(200);
await tester.pumpAndSettle();
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 100);
position.moveTo(0);
await tester.pumpAndSettle();
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 300);
}
// axis: Axis.horizontal, reverse: false
{
await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: false));
await tester.pumpAndSettle();
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
// The width=300 header is at the left of the scroll view and all items are the same width.
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().width, 300);
expect(getHeaderRect().height, 600);
// First and last visible items (assuming < 10 items visible)
final double itemWidth = getItemRect(0).width;
final int visibleItemCount = 500 ~/ itemWidth; // 500 = viewport width - header width
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
// Scrolling up and down leaves the header on the left but changes its width
// between the heights of the min and max extent prototypes.
position.moveTo(200);
await tester.pumpAndSettle();
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().height, 600);
expect(getHeaderRect().width, 100);
position.moveTo(0);
await tester.pumpAndSettle();
expect(getHeaderRect().topLeft, Offset.zero);
expect(getHeaderRect().height, 600);
expect(getHeaderRect().width, 300);
}
// axis: Axis.vertical, reverse: true
{
await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: true));
await tester.pumpAndSettle();
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
// The height=300 header is at the bottom of the scroll view and all items are the same height.
expect(getHeaderRect().bottomLeft, const Offset(0, 600));
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 300);
// First and last visible items (assuming < 10 items visible)
final double itemHeight = getItemRect(0).height;
final int visibleItemCount = 300 ~/ itemHeight; // 300 = viewport height - header height
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
// Scrolling up and down leaves the header at the bottom but changes its height
// between the heights of the min and max extent prototypes.
position.moveTo(200);
await tester.pumpAndSettle();
expect(getHeaderRect().bottomLeft, const Offset(0, 600));
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 100);
position.moveTo(0);
await tester.pumpAndSettle();
expect(getHeaderRect().bottomLeft, const Offset(0, 600));
expect(getHeaderRect().width, 800);
expect(getHeaderRect().height, 300);
}
// axis: Axis.horizontal, reverse: true
{
await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: true));
await tester.pumpAndSettle();
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
// The width=300 header is on the right of the scroll view and all items are the same width.
expect(getHeaderRect().topRight, const Offset(800, 0));
expect(getHeaderRect().width, 300);
expect(getHeaderRect().height, 600);
final double itemWidth = getItemRect(0).width;
final int visibleItemCount = 500 ~/ itemWidth; // 500 = viewport width - header width
expect(find.text('item 0'), findsOneWidget);
expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget);
// Scrolling up and down leaves the header on the left but changes its width
// between the heights of the min and max extent prototypes.
position.moveTo(200);
await tester.pumpAndSettle();
expect(getHeaderRect().topRight, const Offset(800, 0));
expect(getHeaderRect().height, 600);
expect(getHeaderRect().width, 100);
position.moveTo(0);
await tester.pumpAndSettle();
expect(getHeaderRect().topRight, const Offset(800, 0));
expect(getHeaderRect().height, 600);
expect(getHeaderRect().width, 300);
}
});
testWidgets('SliverResizingHeader default minExtent is 0', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverResizingHeader(
maxExtentPrototype: SizedBox(height: 300),
child: SizedBox.expand(child: Text('header')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Text('item $index'),
childCount: 100,
),
),
],
),
),
),
);
expect(tester.getSize(find.text('header')).height, 300);
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.moveTo(299);
await tester.pumpAndSettle();
expect(tester.getSize(find.text('header')).height, 1);
position.moveTo(300);
await tester.pumpAndSettle();
expect(find.text('header'), findsNothing);
});
testWidgets('SliverResizingHeader with identcial min/max prototypes is effectively a pinned header', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
const SliverResizingHeader(
minExtentPrototype: SizedBox(height: 100),
maxExtentPrototype: SizedBox(height: 100),
child: SizedBox.expand(child: Text('header')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Text('item $index'),
childCount: 100,
),
),
],
),
),
),
);
expect(tester.getTopLeft(find.text('header')), Offset.zero);
expect(tester.getSize(find.text('header')), const Size(800, 100));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.moveTo(100);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('header')), Offset.zero);
expect(tester.getSize(find.text('header')), const Size(800, 100));
position.moveTo(0);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text('header')), Offset.zero);
expect(tester.getSize(find.text('header')), const Size(800, 100));
});
testWidgets('SliverResizingHeader default maxExtent matches the child', (WidgetTester tester) async {
final Key headerKey = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverResizingHeader(
child: SizedBox(key: headerKey, height: 300),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Text('item $index'),
childCount: 100,
),
),
],
),
),
),
);
expect(tester.getSize(find.byKey(headerKey)).height, 300);
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.moveTo(299);
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(headerKey)).height, 1);
position.moveTo(300);
await tester.pumpAndSettle();
expect(find.byKey(headerKey), findsNothing);
});
testWidgets('SliverResizingHeader overrides initial out of bounds child size', (WidgetTester tester) async {
Widget buildFrame(double childHeight) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverResizingHeader(
minExtentPrototype: const SizedBox(height: 100),
maxExtentPrototype: const SizedBox(height: 300),
child: SizedBox(height: childHeight, child: const Text('header')),
),
],
),
),
);
}
await tester.pumpWidget(buildFrame(50));
expect(tester.getSize(find.text('header')).height, 100);
await tester.pumpWidget(buildFrame(350));
expect(tester.getSize(find.text('header')).height, 300);
});
testWidgets('SliverResizingHeader update prototypes', (WidgetTester tester) async {
Widget buildFrame(double minHeight, double maxHeight) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverResizingHeader(
minExtentPrototype: SizedBox(height: minHeight),
maxExtentPrototype: SizedBox(height: maxHeight),
child: const SizedBox(height: 300, child: Text('header')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => SizedBox(height: 50, child: Text('$index')),
childCount: 100,
),
),
],
),
),
);
}
double getHeaderHeight() => tester.getSize(find.text('header')).height;
await tester.pumpWidget(buildFrame(100, 300));
expect(getHeaderHeight(), 300);
// Scroll more than needed to reach the min and max header heights.
await tester.drag(find.byType(CustomScrollView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(getHeaderHeight(), 100);
await tester.drag(find.byType(CustomScrollView), const Offset(0, 300));
await tester.pumpAndSettle();
expect(getHeaderHeight(), 300);
// Change min,maxExtentPrototype widget heights from 150,200 to
await tester.pumpWidget(buildFrame(150, 200));
expect(getHeaderHeight(), 200);
await tester.drag(find.byType(CustomScrollView), const Offset(0, -100));
await tester.pumpAndSettle();
expect(getHeaderHeight(), 150);
await tester.drag(find.byType(CustomScrollView), const Offset(0, 100));
await tester.pumpAndSettle();
expect(getHeaderHeight(), 200);
});
}