Skip to content

Commit 9ce0a9e

Browse files
authored
PinnedHeaderSliver (#143196)
A sliver that remains �pinned� to the top of the scroll view. Subsequent slivers scroll behind it. Typically the sliver is created as the first item in the list however it can be inserted anywhere and it will always stop at the top of the scroll view. When the scrolling axis is vertical, the PinnedHeaderSliver�s height is defined by its widget child. Multiple PinnedHeaderSlivers will layout one after the other, once they've scrolled to the top. 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 size. Here's a [working demo in DartPad](https://dartpad.dev/?id=3b3f24c14fa201f752407a21ca9c9456). https://github.com/flutter/flutter/assets/1377460/943f2e02-8e73-48b7-90be-61168978ff71 Related sliver utility PRs: flutter/flutter#143538, flutter/flutter#143325, flutter/flutter#127340.
1 parent 6bdebcf commit 9ce0a9e

File tree

6 files changed

+506
-1
lines changed

6 files changed

+506
-1
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [PinnedHeaderSliver].
8+
9+
void main() {
10+
runApp(const PinnedHeaderSliverApp());
11+
}
12+
13+
class PinnedHeaderSliverApp extends StatelessWidget {
14+
const PinnedHeaderSliverApp({ super.key });
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
return const MaterialApp(
19+
home: PinnedHeaderSliverExample(),
20+
);
21+
}
22+
}
23+
24+
class PinnedHeaderSliverExample extends StatefulWidget {
25+
const PinnedHeaderSliverExample({ super.key });
26+
27+
@override
28+
State<PinnedHeaderSliverExample> createState() => _PinnedHeaderSliverExampleState();
29+
}
30+
31+
class _PinnedHeaderSliverExampleState extends State<PinnedHeaderSliverExample> {
32+
int count = 0;
33+
late final ScrollController scrollController;
34+
35+
@override
36+
void initState() {
37+
super.initState();
38+
scrollController = ScrollController();
39+
}
40+
41+
@override
42+
void dispose() {
43+
scrollController.dispose();
44+
super.dispose();
45+
}
46+
47+
@override
48+
Widget build(BuildContext context) {
49+
final ThemeData theme = Theme.of(context);
50+
final ColorScheme colorScheme = theme.colorScheme;
51+
52+
final Widget header = Container(
53+
color: colorScheme.background,
54+
padding: const EdgeInsets.all(4),
55+
child: Material(
56+
color: colorScheme.primaryContainer,
57+
shape: RoundedRectangleBorder(
58+
borderRadius: BorderRadius.circular(8),
59+
side: BorderSide(
60+
width: 7,
61+
color: colorScheme.outline,
62+
),
63+
),
64+
child: Container(
65+
alignment: Alignment.center,
66+
padding: const EdgeInsets.symmetric(vertical: 48),
67+
child: Text(
68+
count.isOdd ? 'Alternative Title\nWith Two Lines' : 'PinnedHeaderSliver',
69+
style: theme.textTheme.headlineMedium!.copyWith(
70+
color: colorScheme.onPrimaryContainer,
71+
),
72+
),
73+
),
74+
),
75+
);
76+
77+
return Scaffold(
78+
body: SafeArea(
79+
child: Padding(
80+
padding: const EdgeInsets.symmetric(horizontal: 4),
81+
child: CustomScrollView(
82+
controller: scrollController,
83+
slivers: <Widget>[
84+
PinnedHeaderSliver(child: header),
85+
const ItemList(),
86+
],
87+
),
88+
),
89+
),
90+
floatingActionButton: FloatingActionButton(
91+
onPressed: () {
92+
setState(() {
93+
count += 1;
94+
});
95+
},
96+
child: const Icon(Icons.add),
97+
),
98+
);
99+
}
100+
}
101+
102+
// A placeholder SliverList of 25 items.
103+
class ItemList extends StatelessWidget {
104+
const ItemList({
105+
super.key,
106+
this.itemCount = 25,
107+
});
108+
109+
final int itemCount;
110+
111+
@override
112+
Widget build(BuildContext context) {
113+
final ColorScheme colorScheme = Theme.of(context).colorScheme;
114+
return SliverList(
115+
delegate: SliverChildBuilderDelegate(
116+
(BuildContext context, int index) {
117+
return Card(
118+
color: colorScheme.onSecondary,
119+
child: ListTile(
120+
textColor: colorScheme.secondary,
121+
title: Text('Item $index'),
122+
),
123+
);
124+
},
125+
childCount: itemCount,
126+
),
127+
);
128+
}
129+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/widgets/sliver/pinned_header_sliver.0.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('PinnedHeaderSliver example', (WidgetTester tester) async {
11+
await tester.pumpWidget(
12+
const example.PinnedHeaderSliverApp(),
13+
);
14+
15+
expect(find.text('PinnedHeaderSliver'), findsOneWidget);
16+
17+
await tester.tap(find.byType(FloatingActionButton));
18+
await tester.pumpAndSettle();
19+
expect(find.text('Alternative Title\nWith Two Lines'), findsOneWidget);
20+
});
21+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
6+
import 'dart:math' as math;
7+
8+
import 'package:flutter/foundation.dart';
9+
import 'package:flutter/rendering.dart';
10+
11+
import 'framework.dart';
12+
13+
/// A sliver that keeps its Widget child at the top of the a [CustomScrollView].
14+
///
15+
/// This sliver is preferable to the general purpose [SliverPersistentHeader]
16+
/// for its relatively narrow use case because there's no need to create a
17+
/// [SliverPersistentHeaderDelegate] or to predict the header's size.
18+
///
19+
/// {@tool dartpad}
20+
/// This example demonstrates that the sliver's size can change. Pressing the
21+
/// floating action button replaces the one line of header text with two lines.
22+
///
23+
/// ** See code in examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart **
24+
/// {@end-tool}
25+
///
26+
///
27+
/// See also:
28+
///
29+
/// * [SliverResizingHeader] - which similarly pins the header at the top
30+
/// of the [CustomScrollView] but reacts to scrolling by resizing the header
31+
/// between its minimum and maximum extent limits.
32+
/// * [SliverPersistentHeader] - a general purpose header that can be
33+
/// configured as a pinned, resizing, or floating header.
34+
class PinnedHeaderSliver extends SingleChildRenderObjectWidget {
35+
/// Creates a sliver whose [Widget] child appears at the top of a
36+
/// [CustomScrollView].
37+
const PinnedHeaderSliver({
38+
super.key,
39+
super.child,
40+
});
41+
42+
@override
43+
RenderObject createRenderObject(BuildContext context) {
44+
return _RenderPinnedHeaderSliver();
45+
}
46+
}
47+
48+
class _RenderPinnedHeaderSliver extends RenderSliverSingleBoxAdapter {
49+
_RenderPinnedHeaderSliver();
50+
51+
double get childExtent {
52+
if (child == null) {
53+
return 0.0;
54+
}
55+
assert(child!.hasSize);
56+
return switch (constraints.axis) {
57+
Axis.vertical => child!.size.height,
58+
Axis.horizontal => child!.size.width,
59+
};
60+
}
61+
62+
@override
63+
double childMainAxisPosition(covariant RenderObject child) => 0;
64+
65+
@override
66+
void performLayout() {
67+
final SliverConstraints constraints = this.constraints;
68+
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
69+
70+
final double layoutExtent = clampDouble(childExtent - constraints.scrollOffset, 0, constraints.remainingPaintExtent);
71+
final double paintExtent = math.min(childExtent, constraints.remainingPaintExtent - constraints.overlap);
72+
geometry = SliverGeometry(
73+
scrollExtent: childExtent,
74+
paintOrigin: constraints.overlap,
75+
paintExtent: paintExtent,
76+
layoutExtent: layoutExtent,
77+
maxPaintExtent: childExtent,
78+
maxScrollObstructionExtent: childExtent,
79+
cacheExtent: calculateCacheOffset(constraints, from: 0.0, to: childExtent),
80+
hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
81+
);
82+
}
83+
}

packages/flutter/lib/src/widgets/sliver_resizing_header.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ import 'slotted_render_object_widget.dart';
3636
///
3737
/// ** See code in examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart **
3838
/// {@end-tool}
39-
// TODO(hansmuller): add See also links to PersistentHeaderSliver, SliverFloatingHeader, etc
39+
///
40+
/// See also:
41+
///
42+
/// * [PinnedHeaderSliver] - which just pins the header at the top
43+
/// of the [CustomScrollView].
44+
/// * [SliverPersistentHeader] - a general purpose header that can be
45+
/// configured as a pinned, resizing, or floating header.
4046
class SliverResizingHeader extends StatelessWidget {
4147
/// Create a pinned header sliver that reacts to scrolling by resizing between
4248
/// the intrinsic sizes of the min and max extent prototypes.

packages/flutter/lib/widgets.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export 'src/widgets/page_storage.dart';
9393
export 'src/widgets/page_view.dart';
9494
export 'src/widgets/pages.dart';
9595
export 'src/widgets/performance_overlay.dart';
96+
export 'src/widgets/pinned_header_sliver.dart';
9697
export 'src/widgets/placeholder.dart';
9798
export 'src/widgets/platform_menu_bar.dart';
9899
export 'src/widgets/platform_selectable_region_context_menu.dart';

0 commit comments

Comments
 (0)