Skip to content

Commit f2be126

Browse files
authored
Added SliverFloatingHeader.snapMode (#151289)
When a user scroll gesture ends, Material Design floating headers snap into place by animating as far as needed and overlaying the underlying scrollable content. For example Gmail's search header works this way. Other apps handle the snap animation by scrolling content out of the way. Instagram for example. Added `SliverFloatingHeader.snapMode`, whose value can be `FloatingHeaderSnapMode.overlay` (the default) or `FloatingHeaderSnapMode.scroll`, so that developers can choose the snap animation style they want. | FloatingHeaderSnapMode.overlay | FloatingHeaderSnapMode.scroll | | --- | --- | | <video src="https://github.com/flutter/flutter/assets/1377460/05c82ddf-05a6-4431-9b1e-88b901feea68" /> | <video src="https://github.com/flutter/flutter/assets/1377460/fedc34de-0b55-4f0d-976f-2df1965c90bc" /> |
1 parent b713445 commit f2be126

File tree

2 files changed

+129
-2
lines changed

2 files changed

+129
-2
lines changed

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

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ import 'scroll_position.dart';
1313
import 'scrollable.dart';
1414
import 'ticker_provider.dart';
1515

16+
/// Specifies how a partially visible [SliverFloatingHeader] animates
17+
/// into a view when a user scroll gesture ends.
18+
///
19+
/// During a user scroll gesture the header and the rest of the scrollable
20+
/// content move in sync. If the header is partially visible when the
21+
/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
22+
/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
23+
/// content as it expands until it's completely visible, or if the
24+
/// content should scroll out of the way as the header expands.
25+
enum FloatingHeaderSnapMode {
26+
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
27+
/// expand over the scrollable's content.
28+
overlay,
29+
30+
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
31+
/// expand and the scrollable's content will continue to scroll out
32+
/// of the way.
33+
scroll,
34+
}
35+
1636
/// A sliver that shows its [child] when the user scrolls forward and hides it
1737
/// when the user scrolls backwards.
1838
///
@@ -42,6 +62,7 @@ class SliverFloatingHeader extends StatefulWidget {
4262
const SliverFloatingHeader({
4363
super.key,
4464
this.animationStyle,
65+
this.snapMode,
4566
required this.child
4667
});
4768

@@ -51,6 +72,13 @@ class SliverFloatingHeader extends StatefulWidget {
5172
/// The reverse duration and curve apply to the animation that hides the header.
5273
final AnimationStyle? animationStyle;
5374

75+
/// Specifies how a partially visible [SliverFloatingHeader] animates
76+
/// into a view when a user scroll gesture ends.
77+
///
78+
/// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
79+
/// modify an animation in progress, just subsequent animations.
80+
final FloatingHeaderSnapMode? snapMode;
81+
5482
/// The widget contained by this sliver.
5583
final Widget child;
5684

@@ -66,6 +94,7 @@ class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with Single
6694
return _SliverFloatingHeader(
6795
vsync: this,
6896
animationStyle: widget.animationStyle,
97+
snapMode: widget.snapMode,
6998
child: _SnapTrigger(widget.child),
7099
);
71100
}
@@ -118,32 +147,37 @@ class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
118147
const _SliverFloatingHeader({
119148
this.vsync,
120149
this.animationStyle,
150+
this.snapMode,
121151
super.child,
122152
});
123153

124154
final TickerProvider? vsync;
125155
final AnimationStyle? animationStyle;
156+
final FloatingHeaderSnapMode? snapMode;
126157

127158
@override
128159
_RenderSliverFloatingHeader createRenderObject(BuildContext context) {
129160
return _RenderSliverFloatingHeader(
130161
vsync: vsync,
131162
animationStyle: animationStyle,
163+
snapMode: snapMode,
132164
);
133165
}
134166

135167
@override
136168
void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
137169
renderObject
138170
..vsync = vsync
139-
..animationStyle = animationStyle;
171+
..animationStyle = animationStyle
172+
..snapMode = snapMode;
140173
}
141174
}
142175

143176
class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
144177
_RenderSliverFloatingHeader({
145178
TickerProvider? vsync,
146179
this.animationStyle,
180+
this.snapMode,
147181
}) : _vsync = vsync;
148182

149183
late Animation<double> snapAnimation;
@@ -173,6 +207,8 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
173207

174208
AnimationStyle? animationStyle;
175209

210+
FloatingHeaderSnapMode? snapMode;
211+
176212
// Called each time the position's isScrollingNotifier indicates that user scrolling has
177213
// stopped or started, i.e. if the sliver "is scrolling".
178214
void isScrollingUpdate(ScrollPosition position) {
@@ -265,7 +301,10 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
265301

266302
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
267303
final double paintExtent = childExtent - effectiveScrollOffset;
268-
final double layoutExtent = childExtent - constraints.scrollOffset;
304+
final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
305+
FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
306+
FloatingHeaderSnapMode.scroll => paintExtent,
307+
};
269308
geometry = SliverGeometry(
270309
paintOrigin: math.min(constraints.overlap, 0.0),
271310
scrollExtent: childExtent,

packages/flutter/test/widgets/sliver_floating_header_test.dart

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,92 @@ void main() {
234234
await tester.pump(const Duration(milliseconds: 500));
235235
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
236236
});
237+
238+
testWidgets('SliverFloatingHeader snapMode parameter', (WidgetTester tester) async {
239+
Widget buildFrame(FloatingHeaderSnapMode snapMode) {
240+
return MaterialApp(
241+
home: Scaffold(
242+
body: CustomScrollView(
243+
slivers: <Widget>[
244+
SliverFloatingHeader(
245+
snapMode: snapMode,
246+
child: const SizedBox(height: 200, child: Text('header')),
247+
),
248+
SliverList(
249+
delegate: SliverChildBuilderDelegate(
250+
(BuildContext context, int index) {
251+
return SizedBox(height: 100, child: Text('item $index'));
252+
},
253+
childCount: 100,
254+
),
255+
),
256+
],
257+
),
258+
),
259+
);
260+
}
261+
262+
Rect getHeaderRect() => tester.getRect(find.text('header'));
263+
double getItem0Y() => tester.getRect(find.text('item 0')).topLeft.dy;
264+
265+
Future<void> scroll(Offset offset) async {
266+
return tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500));
267+
}
268+
269+
// FloatingHeaderSnapMode.overlay
270+
{
271+
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.overlay));
272+
await tester.pumpAndSettle();
273+
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
274+
expect(getItem0Y(), 200);
275+
276+
// Scrolling in this direction will move more than 200 because
277+
// timedDrag() concludes with a fling and there's room for a
278+
// 200+ scroll.
279+
await scroll(const Offset(0, -200));
280+
await tester.pumpAndSettle();
281+
expect(find.text('header'), findsNothing);
282+
final double item0StartY = getItem0Y();
283+
expect(item0StartY, lessThan(0));
284+
285+
// Trigger the appearance of the floating header. There's no
286+
// fling component to the scroll in this case because the scroll
287+
// offset is small.
288+
await scroll(const Offset(0, 25));
289+
await tester.pumpAndSettle();
290+
291+
// Item0 has only moved as far as the scroll because
292+
// the snapMode is overlay.
293+
expect(getItem0Y(), item0StartY + 25);
294+
295+
// Return the header and item0 to their initial layout.
296+
await scroll(const Offset(0, 200));
297+
await tester.pumpAndSettle();
298+
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
299+
expect(getItem0Y(), 200);
300+
}
301+
302+
// FloatingHeaderSnapMode.scroll
303+
{
304+
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.scroll));
305+
await tester.pumpAndSettle();
306+
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
307+
expect(getItem0Y(), 200);
308+
309+
await scroll(const Offset(0, -200));
310+
await tester.pumpAndSettle();
311+
expect(find.text('header'), findsNothing);
312+
final double item0StartY = getItem0Y();
313+
expect(item0StartY, lessThan(0));
314+
315+
// Trigger the appearance of the floating header.
316+
await scroll(const Offset(0, 25));
317+
await tester.pumpAndSettle();
318+
319+
// Item0 has moved as far as the scroll (25) plus the height of
320+
// the header (200) because the snapMode is scroll and the
321+
// entire header had to snap in.
322+
expect(getItem0Y(), item0StartY + 200 + 25);
323+
}
324+
});
237325
}

0 commit comments

Comments
 (0)