diff --git a/examples/api/lib/widgets/sliver/sliver_floating_header.0.dart b/examples/api/lib/widgets/sliver/sliver_floating_header.0.dart new file mode 100644 index 0000000000..6d44659c65 --- /dev/null +++ b/examples/api/lib/widgets/sliver/sliver_floating_header.0.dart @@ -0,0 +1,121 @@ +// 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 SliverFloatingHeaderApp()); +} + +class SliverFloatingHeaderApp extends StatelessWidget { + const SliverFloatingHeaderApp({ super.key }); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: FloatingHeaderExample(), + ); + } +} + +class FloatingHeaderExample extends StatefulWidget { + const FloatingHeaderExample({ super.key }); + + @override + State createState() => _FloatingHeaderExampleState(); +} + +class _FloatingHeaderExampleState extends State { + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.all(4), + child: CustomScrollView( + slivers: [ + SliverFloatingHeader( + child: ListHeader( + text: 'SliverFloatingHeader\nScroll down a little to show\nScroll up a little to hide', + ), + ), + 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, + ), + ); + } +} diff --git a/examples/api/test/widgets/sliver/sliver_floating_header.0_test.dart b/examples/api/test/widgets/sliver/sliver_floating_header.0_test.dart new file mode 100644 index 0000000000..883910aeae --- /dev/null +++ b/examples/api/test/widgets/sliver/sliver_floating_header.0_test.dart @@ -0,0 +1,30 @@ +// 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_floating_header.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SliverFloatingHeader example', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverFloatingHeaderApp(), + ); + + final Finder headerText = find.text('SliverFloatingHeader\nScroll down a little to show\nScroll up a little to hide'); + final double headerHeight = tester.getSize(headerText).height; + + await tester.drag(find.byType(CustomScrollView), Offset(0, -2 * headerHeight)); + await tester.pumpAndSettle(); + expect(headerText, findsNothing); + + await tester.drag(find.byType(CustomScrollView), Offset(0, 0.5 * headerHeight)); + await tester.pumpAndSettle(); + expect(headerText, findsOneWidget); + + await tester.drag(find.byType(CustomScrollView), Offset(0, -0.5 * headerHeight)); + await tester.pumpAndSettle(); + expect(headerText, findsNothing); + }); +} diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 44f49601b0..939c5eaef5 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -2222,6 +2222,9 @@ FocusNode? get primaryFocus => WidgetsBinding.instance.focusManager.primaryFocus String debugDescribeFocusTree() { String? result; assert(() { + // TODO(yjbanov): remove this once https://github.com/dart-lang/sdk/issues/56129 has been fixed. + // ignore: unnecessary_statements + FocusManager.instance.toStringDeep; result = FocusManager.instance.toStringDeep(); return true; }()); diff --git a/packages/flutter/lib/src/widgets/pinned_header_sliver.dart b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart index 61f337eee8..662e756647 100644 --- a/packages/flutter/lib/src/widgets/pinned_header_sliver.dart +++ b/packages/flutter/lib/src/widgets/pinned_header_sliver.dart @@ -29,6 +29,8 @@ import 'framework.dart'; /// * [SliverResizingHeader] - which similarly pins the header at the top /// of the [CustomScrollView] but reacts to scrolling by resizing the header /// between its minimum and maximum extent limits. +/// * [SliverFloatingHeader] - which animates the header in and out of view +/// in response to downward and upwards scrolls. /// * [SliverPersistentHeader] - a general purpose header that can be /// configured as a pinned, resizing, or floating header. class PinnedHeaderSliver extends SingleChildRenderObjectWidget { diff --git a/packages/flutter/lib/src/widgets/sliver_floating_header.dart b/packages/flutter/lib/src/widgets/sliver_floating_header.dart new file mode 100644 index 0000000000..e9999d0b53 --- /dev/null +++ b/packages/flutter/lib/src/widgets/sliver_floating_header.dart @@ -0,0 +1,304 @@ +// 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/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'framework.dart'; +import 'scroll_position.dart'; +import 'scrollable.dart'; +import 'ticker_provider.dart'; + +/// A sliver that shows its [child] when the user scrolls forward and hides it +/// when the user scrolls backwards. +/// +/// 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. +/// +/// {@tool dartpad} +/// This example shows how to create a SliverFloatingHeader. +/// +/// ** See code in examples/api/lib/widgets/sliver/sliver_floating_header.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [PinnedHeaderSliver] - which just pins the header at the top +/// of the [CustomScrollView]. +/// * [SliverResizingHeader] - which similarly pins the header at the top +/// of the [CustomScrollView] but reacts to scrolling by resizing the header +/// between its minimum and maximum extent limits. +/// * [SliverPersistentHeader] - a general purpose header that can be +/// configured as a pinned, resizing, or floating header. +class SliverFloatingHeader extends StatefulWidget { + /// Create a floating header sliver that animates into view when the user + /// scrolls forward, and disappears the user starts scrolling in the + /// opposite direction. + const SliverFloatingHeader({ + super.key, + this.animationStyle, + required this.child + }); + + /// Non null properties override the default durations (300ms) and + /// curves (Curves.easeInOut) for subsequent header animations. + /// + /// The reverse duration and curve apply to the animation that hides the header. + final AnimationStyle? animationStyle; + + /// The widget contained by this sliver. + final Widget child; + + @override + State createState() => _SliverFloatingHeaderState(); +} + +class _SliverFloatingHeaderState extends State with SingleTickerProviderStateMixin { + ScrollPosition? position; + + @override + Widget build(BuildContext context) { + return _SliverFloatingHeader( + vsync: this, + animationStyle: widget.animationStyle, + child: _SnapTrigger(widget.child), + ); + } +} + +class _SnapTrigger extends StatefulWidget { + const _SnapTrigger(this.child); + + final Widget child; + + @override + _SnapTriggerState createState() => _SnapTriggerState(); +} + +class _SnapTriggerState extends State<_SnapTrigger> { + ScrollPosition? position; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (position != null) { + position!.isScrollingNotifier.removeListener(isScrollingListener); + } + position = Scrollable.maybeOf(context)?.position; + if (position != null) { + position!.isScrollingNotifier.addListener(isScrollingListener); + } + } + + @override + void dispose() { + if (position != null) { + position!.isScrollingNotifier.removeListener(isScrollingListener); + } + super.dispose(); + } + + // Called when the sliver starts or ends scrolling. + void isScrollingListener() { + assert(position != null); + final _RenderSliverFloatingHeader? renderer = context.findAncestorRenderObjectOfType<_RenderSliverFloatingHeader>(); + renderer?.isScrollingUpdate(position!); + } + + @override + Widget build(BuildContext context) => widget.child; +} + +class _SliverFloatingHeader extends SingleChildRenderObjectWidget { + const _SliverFloatingHeader({ + this.vsync, + this.animationStyle, + super.child, + }); + + final TickerProvider? vsync; + final AnimationStyle? animationStyle; + + @override + _RenderSliverFloatingHeader createRenderObject(BuildContext context) { + return _RenderSliverFloatingHeader( + vsync: vsync, + animationStyle: animationStyle, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverFloatingHeader renderObject) { + renderObject + ..vsync = vsync + ..animationStyle = animationStyle; + } +} + +class _RenderSliverFloatingHeader extends RenderSliverSingleBoxAdapter { + _RenderSliverFloatingHeader({ + TickerProvider? vsync, + this.animationStyle, + }) : _vsync = vsync; + + late Animation snapAnimation; + AnimationController? snapController; + double? lastScrollOffset; + + // The distance from the start of the header to the start of the viewport. Whent the + // header is showing it varies between 0 (completely visible) and childExtent (not visible + // because it's just abopve the viewport's starting edge). It's used to compute the + // header's paintExtent which defines where the header will appear - see paint(). + late double effectiveScrollOffset; + + TickerProvider? get vsync => _vsync; + TickerProvider? _vsync; + set vsync(TickerProvider? value) { + if (value == _vsync) { + return; + } + _vsync = value; + if (value == null) { + snapController?.dispose(); + snapController = null; + } else { + snapController?.resync(value); + } + } + + AnimationStyle? animationStyle; + + // 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) { + if (position.isScrollingNotifier.value) { + snapController?.stop(); + } else { + final ScrollDirection direction = position.userScrollDirection; + final bool headerIsPartiallyVisible = switch (direction) { + ScrollDirection.forward when effectiveScrollOffset <= 0 => false, // completely visible + ScrollDirection.reverse when effectiveScrollOffset >= childExtent => false, // not visible + _ => true, + }; + if (headerIsPartiallyVisible) { + snapController ??= AnimationController(vsync: vsync!) + ..addListener(() { + if (effectiveScrollOffset != snapAnimation.value) { + effectiveScrollOffset = snapAnimation.value; + markNeedsLayout(); + } + }); + snapController!.duration = switch (direction) { + ScrollDirection.forward => animationStyle?.duration ?? const Duration(milliseconds: 300), + _ => animationStyle?.reverseDuration ?? const Duration(milliseconds: 300), + }; + snapAnimation = snapController!.drive( + Tween( + begin: effectiveScrollOffset, + end: switch (direction) { + ScrollDirection.forward => 0, + _ => childExtent, + }, + ).chain( + CurveTween( + curve: switch (direction) { + ScrollDirection.forward => animationStyle?.curve ?? Curves.easeInOut, + _ => animationStyle?.reverseCurve ?? Curves.easeInOut, + } + ), + ), + ); + snapController!.forward(from: 0.0); + } + } + } + + double get childExtent { + if (child == null) { + return 0.0; + } + assert(child!.hasSize); + return switch (constraints.axis) { + Axis.vertical => child!.size.height, + Axis.horizontal => child!.size.width, + }; + } + + @override + void detach() { + snapController?.dispose(); + snapController = null; // lazily recreated if we're reattached. + super.detach(); + } + + // True if the header has been laid at at least once (lastScrollOffset != null) and either: + // - We're scrolling forward: constraints.scrollOffset < lastScrollOffset + // - The header's already partially visible: effectiveScrollOffset < childExtent + // Scrolling forwards (towards the scrollable's start) is the trigger that causes the + // header to be shown. + bool get floatingHeaderNeedsToBeUpdated { + return lastScrollOffset != null && + (constraints.scrollOffset < lastScrollOffset! || effectiveScrollOffset < childExtent); + } + + @override + void performLayout() { + if (!floatingHeaderNeedsToBeUpdated) { + effectiveScrollOffset = constraints.scrollOffset; + } else { + double delta = lastScrollOffset! - constraints.scrollOffset; // > 0 when the header is growing + if (constraints.userScrollDirection == ScrollDirection.forward) { + if (effectiveScrollOffset > childExtent) { + effectiveScrollOffset = childExtent; // The header is now just above the start edge of viewport. + } + } else { + // delta > 0 and scrolling forward is a contradiction. Assume that it's noise (set delta to 0). + delta = clampDouble(delta, -double.infinity, 0); + } + effectiveScrollOffset = clampDouble(effectiveScrollOffset - delta, 0.0, constraints.scrollOffset); + } + + child?.layout(constraints.asBoxConstraints(), parentUsesSize: true); + final double paintExtent = childExtent - effectiveScrollOffset; + final double layoutExtent = childExtent - constraints.scrollOffset; + geometry = SliverGeometry( + paintOrigin: math.min(constraints.overlap, 0.0), + scrollExtent: childExtent, + paintExtent: clampDouble(paintExtent, 0.0, constraints.remainingPaintExtent), + layoutExtent: clampDouble(layoutExtent, 0.0, constraints.remainingPaintExtent), + maxPaintExtent: childExtent, + hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity. + ); + + lastScrollOffset = constraints.scrollOffset; + } + + @override + double childMainAxisPosition(covariant RenderObject child) { + return geometry == null ? 0 : math.min(0, geometry!.paintExtent - childExtent); + } + + @override + void applyPaintTransform(RenderObject child, Matrix4 transform) { + assert(child == this.child); + applyPaintTransformForBoxChild(child as RenderBox, transform); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child != null && geometry!.visible) { + offset += switch (applyGrowthDirectionToAxisDirection(constraints.axisDirection, constraints.growthDirection)) { + AxisDirection.up => Offset(0.0, geometry!.paintExtent - childMainAxisPosition(child!) - childExtent), + AxisDirection.left => Offset(geometry!.paintExtent - childMainAxisPosition(child!) - childExtent, 0.0), + AxisDirection.right => Offset(childMainAxisPosition(child!), 0.0), + AxisDirection.down => Offset(0.0, childMainAxisPosition(child!)), + }; + context.paintChild(child!, offset); + } + } +} diff --git a/packages/flutter/lib/src/widgets/sliver_resizing_header.dart b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart index 38a32ad2c1..1797321f8f 100644 --- a/packages/flutter/lib/src/widgets/sliver_resizing_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_resizing_header.dart @@ -41,6 +41,8 @@ import 'slotted_render_object_widget.dart'; /// /// * [PinnedHeaderSliver] - which just pins the header at the top /// of the [CustomScrollView]. +/// * [SliverFloatingHeader] - which animates the header in and out of view +/// in response to downward and upwards scrolls. /// * [SliverPersistentHeader] - a general purpose header that can be /// configured as a pinned, resizing, or floating header. class SliverResizingHeader extends StatelessWidget { diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 13d80ede3d..42731ac853 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -135,6 +135,7 @@ export 'src/widgets/single_child_scroll_view.dart'; export 'src/widgets/size_changed_layout_notifier.dart'; export 'src/widgets/sliver.dart'; export 'src/widgets/sliver_fill.dart'; +export 'src/widgets/sliver_floating_header.dart'; export 'src/widgets/sliver_layout_builder.dart'; export 'src/widgets/sliver_persistent_header.dart'; export 'src/widgets/sliver_prototype_extent_list.dart'; diff --git a/packages/flutter/test/widgets/sliver_floating_header_test.dart b/packages/flutter/test/widgets/sliver_floating_header_test.dart new file mode 100644 index 0000000000..18df42ffaa --- /dev/null +++ b/packages/flutter/test/widgets/sliver_floating_header_test.dart @@ -0,0 +1,237 @@ +// 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('SliverFloatingHeader basics', (WidgetTester tester) async { + Widget buildFrame({ required Axis axis, required bool reverse }) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + scrollDirection: axis, + reverse: reverse, + slivers: [ + SliverFloatingHeader( + child: switch (axis) { + Axis.vertical => const SizedBox(height: 200, child: Text('header')), + Axis.horizontal => const SizedBox(width: 200, child: Text('header')), + }, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return switch (axis) { + Axis.vertical => SizedBox(height: 100, child: Text('item $index')), + Axis.horizontal => SizedBox(width: 100, child: Text('item $index')), + }; + }, + childCount: 100, + ), + ), + ], + ), + ), + ); + } + + Rect getHeaderRect() => tester.getRect(find.text('header')); + + Future scroll(Offset offset) async { + await tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500)); + return tester.pumpAndSettle(); + } + + // axis: Axis.vertical, reverse: false + { + await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: false)); + await tester.pumpAndSettle(); + + // The test viewport is width=800 x height=600 + // The height=200 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, 200); + + // First and last visible items, each item has height=100 + const int visibleItemCount = 4; // viewport height - header height = 400 + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget); + + // Scroll the header past the top of the viewport. + await scroll(const Offset(0, -200)); + expect(find.text('header'), findsNothing); + + // Scroll in the opposite direction a little to trigger the appearance of the floating header. + await scroll(const Offset(0, 25)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200)); + + // Scrolling further in the same direction, leaves the header where it is. + await scroll(const Offset(0, 25)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200)); + + // Scroll in the original direction a little to trigger the header's disappearance. + await scroll(const Offset(0, -25)); + expect(find.text('header'), findsNothing); + } + + // axis: Axis.horizontal, reverse: false + { + await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: false)); + await tester.pumpAndSettle(); + + expect(getHeaderRect().topLeft, Offset.zero); + expect(getHeaderRect().width, 200); + expect(getHeaderRect().height, 600); + + // First and last visible items. Each item has width=100 + const int visibleItemCount = 6; // 600 = viewport width - header width + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget); + + // Scroll the header past the left edge of the viewport. + await scroll(const Offset(-200, 0)); + expect(find.text('header'), findsNothing); + + // Scroll in the opposite direction a little to trigger the appearance of the floating header. + await scroll(const Offset(25, 0)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 200, 600)); + + // Scrolling further in the same direction, leaves the header where it is. + await scroll(const Offset(25, 0)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 200, 600)); + + // Scroll in the original direction a little to trigger the header's disappearance. + await scroll(const Offset(-25, 0)); + expect(find.text('header'), findsNothing); + } + + // axis: Axis.vertical, reverse: true + { + await tester.pumpWidget(buildFrame(axis: Axis.vertical, reverse: true)); + await tester.pumpAndSettle(); + + expect(getHeaderRect().topLeft, const Offset(0, 400)); + expect(getHeaderRect().width, 800); + expect(getHeaderRect().height, 200); + + // First and last visible items, each item has height=100 + const int visibleItemCount = 4; // viewport height - header height = 400 + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget); + + // Scroll the header past the bottom of the viewport. + await scroll(const Offset(0, 200)); + expect(find.text('header'), findsNothing); + + // Scroll in the opposite direction a little to trigger the appearance of the floating header. + await scroll(const Offset(0, -25)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 400, 800, 600)); + + // Scrolling further in the same direction, leaves the header where it is. + await scroll(const Offset(0, -25)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 400, 800, 600)); + + // Scroll in the original direction a little to trigger the header's disappearance. + await scroll(const Offset(0, 25)); + expect(find.text('header'), findsNothing); + } + + // axis: Axis.horizontal, reverse: true + { + await tester.pumpWidget(buildFrame(axis: Axis.horizontal, reverse: true)); + await tester.pumpAndSettle(); + + expect(getHeaderRect().topLeft, const Offset(600, 0)); + expect(getHeaderRect().width, 200); + expect(getHeaderRect().height, 600); + + // First and last visible items. Each item has width=100 + const int visibleItemCount = 6; // 600 = viewport width - header width + expect(find.text('item 0'), findsOneWidget); + expect(find.text('item ${visibleItemCount - 1}'), findsOneWidget); + + // Scroll the header past the right edge of the viewport. + await scroll(const Offset(200, 0)); + expect(find.text('header'), findsNothing); + + // Scroll in the opposite direction a little to trigger the appearance of the floating header. + await scroll(const Offset(-25, 0)); + expect(getHeaderRect(), const Rect.fromLTRB(600, 0, 800, 600)); + + // Scrolling further in the same direction, leaves the header where it is. + await scroll(const Offset(-25, 0)); + expect(getHeaderRect(), const Rect.fromLTRB(600, 0, 800, 600)); + + // Scroll in the original direction a little to trigger the header's disappearance. + await scroll(const Offset(25, 0)); + expect(find.text('header'), findsNothing); + } + }); + + testWidgets('SliverFloatingHeader override default AnimationStyle', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + SliverFloatingHeader( + animationStyle: AnimationStyle( + curve: Curves.linear, + reverseCurve: Curves.linear, + duration: const Duration(seconds: 1), + reverseDuration: const Duration(seconds: 1), + ), + 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')); + + Future scroll(Offset offset) async { + return tester.timedDrag(find.byType(CustomScrollView), offset, const Duration(milliseconds: 500)); + } + + // The test viewport is width=800 x height=600 + // The height=200 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, 200); + + // Scroll the header past the top of the viewport. + await scroll(const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(find.text('header'), findsNothing); + + // Scroll in the opposite direction a little to trigger the appearance of the floating header. + await scroll(const Offset(0, 25)); + + // Initially the header is where the drag left it => it's moved 25 downwards + expect(getHeaderRect(), const Rect.fromLTRB(0, -175, 800, 25)); + + // With a linear animation curve, after half the animation's duration (500ms), we'll + // have moved downwards half of the remaining 175: + await tester.pump(const Duration(milliseconds: 500)); + expect(getHeaderRect(), const Rect.fromLTRB(0, -175/2, 800, 200 - 175/2)); + + // After the remainder of the animation's duration the header is back + // where it started. + await tester.pump(const Duration(milliseconds: 500)); + expect(getHeaderRect(), const Rect.fromLTRB(0, 0, 800, 200)); + }); +}