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" /> |
This commit is contained in:
Hans Muller 2024-07-08 12:33:14 -07:00 committed by GitHub
parent b713445298
commit f2be1260df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 129 additions and 2 deletions

View File

@ -13,6 +13,26 @@ import 'scroll_position.dart';
import 'scrollable.dart';
import 'ticker_provider.dart';
/// Specifies how a partially visible [SliverFloatingHeader] animates
/// into a view when a user scroll gesture ends.
///
/// During a user scroll gesture the header and the rest of the scrollable
/// content move in sync. If the header is partially visible when the
/// scroll gesture ends, [SliverFloatingHeader.snapMode] specifies if
/// the header should [FloatingHeaderSnapMode.overlay] the scrollable's
/// content as it expands until it's completely visible, or if the
/// content should scroll out of the way as the header expands.
enum FloatingHeaderSnapMode {
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
/// expand over the scrollable's content.
overlay,
/// At the end of a user scroll gesture, the [SliverFloatingHeader] will
/// expand and the scrollable's content will continue to scroll out
/// of the way.
scroll,
}
/// A sliver that shows its [child] when the user scrolls forward and hides it
/// when the user scrolls backwards.
///
@ -42,6 +62,7 @@ class SliverFloatingHeader extends StatefulWidget {
const SliverFloatingHeader({
super.key,
this.animationStyle,
this.snapMode,
required this.child
});
@ -51,6 +72,13 @@ class SliverFloatingHeader extends StatefulWidget {
/// The reverse duration and curve apply to the animation that hides the header.
final AnimationStyle? animationStyle;
/// Specifies how a partially visible [SliverFloatingHeader] animates
/// into a view when a user scroll gesture ends.
///
/// The default is [FloatingHeaderSnapMode.overlay]. This parameter doesn't
/// modify an animation in progress, just subsequent animations.
final FloatingHeaderSnapMode? snapMode;
/// The widget contained by this sliver.
final Widget child;
@ -66,6 +94,7 @@ class _SliverFloatingHeaderState extends State<SliverFloatingHeader> with Single
return _SliverFloatingHeader(
vsync: this,
animationStyle: widget.animationStyle,
snapMode: widget.snapMode,
child: _SnapTrigger(widget.child),
);
}
@ -118,17 +147,20 @@ class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
const _SliverFloatingHeader({
this.vsync,
this.animationStyle,
this.snapMode,
super.child,
});
final TickerProvider? vsync;
final AnimationStyle? animationStyle;
final FloatingHeaderSnapMode? snapMode;
@override
_RenderSliverFloatingHeader createRenderObject(BuildContext context) {
return _RenderSliverFloatingHeader(
vsync: vsync,
animationStyle: animationStyle,
snapMode: snapMode,
);
}
@ -136,7 +168,8 @@ class _SliverFloatingHeader extends SingleChildRenderObjectWidget {
void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) {
renderObject
..vsync = vsync
..animationStyle = animationStyle;
..animationStyle = animationStyle
..snapMode = snapMode;
}
}
@ -144,6 +177,7 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
_RenderSliverFloatingHeader({
TickerProvider? vsync,
this.animationStyle,
this.snapMode,
}) : _vsync = vsync;
late Animation<double> snapAnimation;
@ -173,6 +207,8 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
AnimationStyle? animationStyle;
FloatingHeaderSnapMode? snapMode;
// Called each time the position's isScrollingNotifier indicates that user scrolling has
// stopped or started, i.e. if the sliver "is scrolling".
void isScrollingUpdate(ScrollPosition position) {
@ -265,7 +301,10 @@ class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter {
child?.layout(constraints.asBoxConstraints(), parentUsesSize: true);
final double paintExtent = childExtent - effectiveScrollOffset;
final double layoutExtent = childExtent - constraints.scrollOffset;
final double layoutExtent = switch (snapMode ?? FloatingHeaderSnapMode.overlay) {
FloatingHeaderSnapMode.overlay => childExtent - constraints.scrollOffset,
FloatingHeaderSnapMode.scroll => paintExtent,
};
geometry = SliverGeometry(
paintOrigin: math.min(constraints.overlap, 0.0),
scrollExtent: childExtent,

View File

@ -234,4 +234,92 @@ void main() {
await tester.pump(const Duration(milliseconds: 500));
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
});
testWidgets('SliverFloatingHeader snapMode parameter', (WidgetTester tester) async {
Widget buildFrame(FloatingHeaderSnapMode snapMode) {
return MaterialApp(
home: Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverFloatingHeader(
snapMode: snapMode,
child: const SizedBox(height: 200, child: Text('header')),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return SizedBox(height: 100, child: Text('item $index'));
},
childCount: 100,
),
),
],
),
),
);
}
Rect getHeaderRect() => tester.getRect(find.text('header'));
double getItem0Y() => tester.getRect(find.text('item 0')).topLeft.dy;
Future<void> scroll(Offset offset) async {
return tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500));
}
// FloatingHeaderSnapMode.overlay
{
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.overlay));
await tester.pumpAndSettle();
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
expect(getItem0Y(), 200);
// Scrolling in this direction will move more than 200 because
// timedDrag() concludes with a fling and there's room for a
// 200+ scroll.
await scroll(const Offset(0, -200));
await tester.pumpAndSettle();
expect(find.text('header'), findsNothing);
final double item0StartY = getItem0Y();
expect(item0StartY, lessThan(0));
// Trigger the appearance of the floating header. There's no
// fling component to the scroll in this case because the scroll
// offset is small.
await scroll(const Offset(0, 25));
await tester.pumpAndSettle();
// Item0 has only moved as far as the scroll because
// the snapMode is overlay.
expect(getItem0Y(), item0StartY + 25);
// Return the header and item0 to their initial layout.
await scroll(const Offset(0, 200));
await tester.pumpAndSettle();
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
expect(getItem0Y(), 200);
}
// FloatingHeaderSnapMode.scroll
{
await tester.pumpWidget(buildFrame(FloatingHeaderSnapMode.scroll));
await tester.pumpAndSettle();
expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200));
expect(getItem0Y(), 200);
await scroll(const Offset(0, -200));
await tester.pumpAndSettle();
expect(find.text('header'), findsNothing);
final double item0StartY = getItem0Y();
expect(item0StartY, lessThan(0));
// Trigger the appearance of the floating header.
await scroll(const Offset(0, 25));
await tester.pumpAndSettle();
// Item0 has moved as far as the scroll (25) plus the height of
// the header (200) because the snapMode is scroll and the
// entire header had to snap in.
expect(getItem0Y(), item0StartY + 200 + 25);
}
});
}