diff --git a/packages/flutter/lib/src/widgets/sliver_floating_header.dart b/packages/flutter/lib/src/widgets/sliver_floating_header.dart index e9999d0b531..93df28c62d2 100644 --- a/packages/flutter/lib/src/widgets/sliver_floating_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_floating_header.dart @@ -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 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 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, diff --git a/packages/flutter/test/widgets/sliver_floating_header_test.dart b/packages/flutter/test/widgets/sliver_floating_header_test.dart index 18df42ffaa8..359e7bb9db0 100644 --- a/packages/flutter/test/widgets/sliver_floating_header_test.dart +++ b/packages/flutter/test/widgets/sliver_floating_header_test.dart @@ -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: [ + 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 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); + } + }); }