From 7e3e30929a8766ffafa35c910b0d29630c9428a8 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Thu, 6 Jun 2024 15:35:12 -0700 Subject: [PATCH] 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. --- .../sliver/sliver_resizing_header.0.dart | 145 ++++++++ .../sliver/sliver_resizing_header.0_test.dart | 26 ++ .../src/widgets/sliver_resizing_header.dart | 232 ++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../widgets/sliver_resizing_header_test.dart | 352 ++++++++++++++++++ 5 files changed, 756 insertions(+) create mode 100644 examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart create mode 100644 examples/api/test/widgets/sliver/sliver_resizing_header.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/sliver_resizing_header.dart create mode 100644 packages/flutter/test/widgets/sliver_resizing_header_test.dart diff --git a/examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart b/examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart new file mode 100644 index 0000000000..347a433d43 --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart @@ -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 createState() => _ResizingHeaderExampleState(); +} + +class _ResizingHeaderExampleState extends State { + 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 [ + 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, + ), + ); + } +} diff --git a/examples/api/test/widgets/sliver/sliver_resizing_header.0_test.dart b/examples/api/test/widgets/sliver/sliver_resizing_header.0_test.dart new file mode 100644 index 0000000000..70a5a5ef13 --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_resizing_header.0_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/widgets/sliver_resizing_header.dart b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart new file mode 100644 index 0000000000..6ab57c0988 --- /dev/null +++ b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart @@ -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 get children { + return [ + 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; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index a85573de44..ee4e4ba451 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widgets/sliver_resizing_header_test.dart b/packages/flutter/test/widgets/sliver_resizing_header_test.dart new file mode 100644 index 0000000000..1fd7a08422 --- /dev/null +++ b/packages/flutter/test/widgets/sliver_resizing_header_test.dart @@ -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: [ + 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(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(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(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(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: [ + 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(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: [ + 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(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: [ + 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(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: [ + 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: [ + 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); + }); +}