diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index 1a67acdc31..abdb1af623 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -1067,58 +1067,6 @@ class _AppBarState extends State { } } -class _FloatingAppBar extends StatefulWidget { - const _FloatingAppBar({ Key? key, required this.child }) : super(key: key); - - final Widget child; - - @override - _FloatingAppBarState createState() => _FloatingAppBarState(); -} - -// A wrapper for the widget created by _SliverAppBarDelegate that starts and -// stops the floating app bar's snap-into-view or snap-out-of-view animation. -class _FloatingAppBarState extends State<_FloatingAppBar> { - ScrollPosition? _position; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_position != null) - _position!.isScrollingNotifier.removeListener(_isScrollingListener); - _position = Scrollable.of(context)?.position; - if (_position != null) - _position!.isScrollingNotifier.addListener(_isScrollingListener); - } - - @override - void dispose() { - if (_position != null) - _position!.isScrollingNotifier.removeListener(_isScrollingListener); - super.dispose(); - } - - RenderSliverFloatingPersistentHeader? _headerRenderer() { - return context.findAncestorRenderObjectOfType(); - } - - void _isScrollingListener() { - if (_position == null) - return; - - // When a scroll stops, then maybe snap the appbar into view. - // Similarly, when a scroll starts, then maybe stop the snap animation. - final RenderSliverFloatingPersistentHeader? header = _headerRenderer(); - if (_position!.isScrollingNotifier.value) - header?.maybeStopSnapAnimation(_position!.userScrollDirection); - else - header?.maybeStartSnapAnimation(_position!.userScrollDirection); - } - - @override - Widget build(BuildContext context) => widget.child; -} - class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { _SliverAppBarDelegate({ required this.leading, @@ -1264,7 +1212,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { systemOverlayStyle: systemOverlayStyle, ), ); - return floating ? _FloatingAppBar(child: appBar) : appBar; + return appBar; } @override diff --git a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart index bd4be016ad..3fdac1f6d2 100644 --- a/packages/flutter/lib/src/rendering/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/rendering/sliver_persistent_header.dart @@ -550,6 +550,10 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste late Animation _animation; double? _lastActualScrollOffset; double? _effectiveScrollOffset; + // Important for pointer scrolling, which does not have the same concept of + // a hold and release scroll movement, like dragging. + // This keeps track of the last ScrollDirection when scrolling started. + ScrollDirection? _lastStartedScrollDirection; // Distance from our leading edge to the child's leading edge, in the axis // direction. Negative if we're scrolled off the top. @@ -647,6 +651,11 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste ); } + /// Update the last known ScrollDirection when scrolling began. + void updateScrollStartDirection(ScrollDirection direction) { + _lastStartedScrollDirection = direction; + } + /// If the header isn't already fully exposed, then scroll it into view. void maybeStartSnapAnimation(ScrollDirection direction) { final FloatingHeaderSnapConfiguration? snap = snapConfiguration; @@ -680,7 +689,8 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste (_effectiveScrollOffset! < maxExtent))) { // some part of it is visible, so should shrink or reveal as appropriate. double delta = _lastActualScrollOffset! - constraints.scrollOffset; - final bool allowFloatingExpansion = constraints.userScrollDirection == ScrollDirection.forward; + final bool allowFloatingExpansion = constraints.userScrollDirection == ScrollDirection.forward + || (_lastStartedScrollDirection != null && _lastStartedScrollDirection == ScrollDirection.forward); if (allowFloatingExpansion) { if (_effectiveScrollOffset! > maxExtent) // We're scrolled off-screen, but should reveal, so _effectiveScrollOffset = maxExtent; // pretend we're just at the limit. diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index 2727740a6f..4c43151fb8 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -1103,6 +1103,13 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont delta < 0.0 ? ScrollDirection.forward : ScrollDirection.reverse, ); + // Set the isScrollingNotifier. Even if only one position actually receives + // the delta, the NestedScrollView's intention is to treat multiple + // ScrollPositions as one. + _outerPosition!.isScrollingNotifier.value = true; + for (final _NestedScrollPosition position in _innerPositions) + position.isScrollingNotifier.value = true; + if (_innerPositions.isEmpty) { // Does not enter overscroll. _outerPosition!.applyClampedPointerSignalUpdate(delta); diff --git a/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart b/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart index ec7f8e0ac0..09dea8ad5e 100644 --- a/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart +++ b/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart @@ -216,6 +216,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ); final double oldPixels = pixels; forcePixels(targetPixels); + isScrollingNotifier.value = true; didStartScroll(); didUpdateScrollPositionBy(pixels - oldPixels); didEndScroll(); diff --git a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart index 221d420acd..a7c93df9c4 100644 --- a/packages/flutter/lib/src/widgets/sliver_persistent_header.dart +++ b/packages/flutter/lib/src/widgets/sliver_persistent_header.dart @@ -7,6 +7,8 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart' show TickerProvider; import 'framework.dart'; +import 'scroll_position.dart'; +import 'scrollable.dart'; /// Delegate for configuring a [SliverPersistentHeader]. abstract class SliverPersistentHeaderDelegate { @@ -185,8 +187,72 @@ class SliverPersistentHeader extends StatelessWidget { } } +class _FloatingHeader extends StatefulWidget { + const _FloatingHeader({ Key? key, required this.child }) : super(key: key); + + final Widget child; + + @override + _FloatingHeaderState createState() => _FloatingHeaderState(); +} + +// A wrapper for the widget created by _SliverPersistentHeaderElement that +// starts and stops the floating app bar's snap-into-view or snap-out-of-view +// animation. It also informs the float when pointer scrolling by updating the +// last known ScrollDirection when scrolling began. +class _FloatingHeaderState extends State<_FloatingHeader> { + ScrollPosition? _position; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_position != null) + _position!.isScrollingNotifier.removeListener(_isScrollingListener); + _position = Scrollable.of(context)?.position; + if (_position != null) + _position!.isScrollingNotifier.addListener(_isScrollingListener); + } + + @override + void dispose() { + if (_position != null) + _position!.isScrollingNotifier.removeListener(_isScrollingListener); + super.dispose(); + } + + RenderSliverFloatingPersistentHeader? _headerRenderer() { + return context.findAncestorRenderObjectOfType(); + } + + void _isScrollingListener() { + assert(_position != null); + + // When a scroll stops, then maybe snap the app bar into view. + // Similarly, when a scroll starts, then maybe stop the snap animation. + // Update the scrolling direction as well for pointer scrolling updates. + final RenderSliverFloatingPersistentHeader? header = _headerRenderer(); + if (_position!.isScrollingNotifier.value) { + header?.updateScrollStartDirection(_position!.userScrollDirection); + // Only SliverAppBars support snapping, headers will not snap. + header?.maybeStopSnapAnimation(_position!.userScrollDirection); + } else { + // Only SliverAppBars support snapping, headers will not snap. + header?.maybeStartSnapAnimation(_position!.userScrollDirection); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + class _SliverPersistentHeaderElement extends RenderObjectElement { - _SliverPersistentHeaderElement(_SliverPersistentHeaderRenderObjectWidget widget) : super(widget); + _SliverPersistentHeaderElement( + _SliverPersistentHeaderRenderObjectWidget widget, { + this.floating = false, + }) : assert(floating != null), + super(widget); + + final bool floating; @override _SliverPersistentHeaderRenderObjectWidget get widget => super.widget as _SliverPersistentHeaderRenderObjectWidget; @@ -229,11 +295,13 @@ class _SliverPersistentHeaderElement extends RenderObjectElement { owner!.buildScope(this, () { child = updateChild( child, - widget.delegate.build( - this, - shrinkOffset, - overlapsContent, - ), + floating + ? _FloatingHeader(child: widget.delegate.build( + this, + shrinkOffset, + overlapsContent + )) + : widget.delegate.build(this, shrinkOffset, overlapsContent), null, ); }); @@ -273,13 +341,16 @@ abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWid const _SliverPersistentHeaderRenderObjectWidget({ Key? key, required this.delegate, + this.floating = false, }) : assert(delegate != null), + assert(floating != null), super(key: key); final SliverPersistentHeaderDelegate delegate; + final bool floating; @override - _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this); + _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this, floating: floating); @override _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context); @@ -383,6 +454,7 @@ class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjec }) : super( key: key, delegate: delegate, + floating: true, ); @override @@ -428,6 +500,7 @@ class _SliverFloatingPinnedPersistentHeader extends _SliverPersistentHeaderRende }) : super( key: key, delegate: delegate, + floating: true, ); @override diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index 2bfb94383f..692fda12c9 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -1490,6 +1490,60 @@ void main() { verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); }); + testWidgets('snap with pointer signal', (WidgetTester tester) async { + final GlobalKey appBarKey = GlobalKey(); + await tester.pumpWidget(buildFloatTest( + floating: true, + snap: true, + appBarKey: appBarKey, + )); + + final Offset scrollEventLocation = tester.getCenter(find.byType(NestedScrollView)); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + // Create a hover event so that |testPointer| has a location when generating the scroll. + testPointer.hover(scrollEventLocation); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + + // Scroll away the outer scroll view and some of the inner scroll view. + // We will not scroll back the same amount to indicate that we are + // snapping in before reaching the top of the inner scrollable. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + + // The snap animation should be triggered to expand the app bar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); + await tester.pumpAndSettle(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + + // Scroll away a bit more to trigger the snap close animation. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 30.0))); + await tester.pumpAndSettle(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + }); + testWidgets('float expanded with pointer signal', (WidgetTester tester) async { final GlobalKey appBarKey = GlobalKey(); await tester.pumpWidget(buildFloatTest( diff --git a/packages/flutter/test/widgets/slivers_appbar_floating_test.dart b/packages/flutter/test/widgets/slivers_appbar_floating_test.dart index e91d9cece6..08f6e8bbd4 100644 --- a/packages/flutter/test/widgets/slivers_appbar_floating_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_floating_test.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void verifyPaintPosition(GlobalKey key, Offset ideal, bool visible) { @@ -226,6 +228,230 @@ void main() { expect(tester.getTopLeft(find.byType(Container)), Offset.zero); expect(tester.getTopLeft(find.text('X')), const Offset(0.0, 250.0)); }); + + group('Pointer scrolled floating', () { + Widget buildTest(Widget sliver) { + return MaterialApp( + home: CustomScrollView( + slivers: [ + sliver, + SliverFixedExtentList( + itemExtent: 50.0, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) => Text('Item $index'), + childCount: 30, + ) + ), + ], + ), + ); + } + + void verifyGeometry({ + required GlobalKey key, + required bool visible, + required double paintExtent + }) { + final RenderSliver target = key.currentContext!.findRenderObject()! as RenderSliver; + final SliverGeometry geometry = target.geometry!; + expect(geometry.visible, visible); + expect(geometry.paintExtent, paintExtent); + } + + testWidgets('SliverAppBar', (WidgetTester tester) async { + final GlobalKey appBarKey = GlobalKey(); + await tester.pumpWidget(buildTest(SliverAppBar( + key: appBarKey, + floating: true, + title: const Text('Test Title'), + ))); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, visible: true, paintExtent: 56.0); + + // Pointer scroll the app bar away, we will scroll back less to validate the + // app bar floats back in. + final Offset point1 = tester.getCenter(find.text('Item 5')); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(point1); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + + // Scroll back to float in appbar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 50.0, visible: true); + + // Float the rest of the way in. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -250.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + }); + + testWidgets('SliverPersistentHeader', (WidgetTester tester) async { + final GlobalKey headerKey = GlobalKey(); + await tester.pumpWidget(buildTest(SliverPersistentHeader( + key: headerKey, + floating: true, + delegate: HeaderDelegate(), + ))); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: headerKey, visible: true, paintExtent: 56.0); + + // Pointer scroll the app bar away, we will scroll back less to validate the + // app bar floats back in. + final Offset point1 = tester.getCenter(find.text('Item 5')); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(point1); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: headerKey, paintExtent: 0.0, visible: false); + + // Scroll back to float in appbar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -50.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: headerKey, paintExtent: 50.0, visible: true); + + // Float the rest of the way in. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -250.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: headerKey, paintExtent: 56.0, visible: true); + }); + + testWidgets('and snapping SliverAppBar', (WidgetTester tester) async { + final GlobalKey appBarKey = GlobalKey(); + await tester.pumpWidget(buildTest(SliverAppBar( + key: appBarKey, + floating: true, + snap: true, + title: const Text('Test Title'), + ))); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsOneWidget); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, visible: true, paintExtent: 56.0); + + // Pointer scroll the app bar away, we will scroll back less to validate the + // app bar floats back in and then snaps to full size. + final Offset point1 = tester.getCenter(find.text('Item 5')); + final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse); + testPointer.hover(point1); + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 300.0))); + await tester.pump(); + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + + // Scroll back to float in appbar + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, -30.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 30.0, visible: true); + await tester.pumpAndSettle(); + // The snap animation should have completed and the app bar should be + // fully expanded. + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 56.0, visible: true); + + + // Float back out a bit and trigger snap close animation. + await tester.sendEventToBinding(testPointer.scroll(const Offset(0.0, 50.0))); + await tester.pump(); + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + tester.renderObject(find.byType(AppBar)).size.height, + 56.0, + ); + verifyGeometry(key: appBarKey, paintExtent: 6.0, visible: true); + await tester.pumpAndSettle(); + // The snap animation should have completed and the app bar should no + // longer be visible. + expect(find.text('Test Title'), findsNothing); + expect(find.text('Item 1'), findsNothing); + expect(find.text('Item 5'), findsOneWidget); + expect( + find.byType(AppBar), + findsNothing, + ); + verifyGeometry(key: appBarKey, paintExtent: 0.0, visible: false); + }); + }); +} + +class HeaderDelegate extends SliverPersistentHeaderDelegate { + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + height: 56, + color: Colors.red, + child: const Text('Test Title'), + ); + } + + @override + double get maxExtent => 56; + + @override + double get minExtent => 56; + + @override + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => false; } class TestDelegate extends SliverPersistentHeaderDelegate {