forked from firka/flutter
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:
145
examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart
Normal file
145
examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
232
packages/flutter/lib/src/widgets/sliver_resizing_header.dart
Normal file
232
packages/flutter/lib/src/widgets/sliver_resizing_header.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
352
packages/flutter/test/widgets/sliver_resizing_header_test.dart
Normal file
352
packages/flutter/test/widgets/sliver_resizing_header_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user