diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index 086095c70b..65bd565c2a 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -737,24 +737,24 @@ class _AppBarState extends State { static const double _defaultElevation = 4.0; static const Color _defaultShadowColor = Color(0xFF000000); - ScrollNotificationObserverState? _scrollNotificationObserver; + ScrollMetricsNotificationObserverState? _scrollMetricsNotificationObserver; bool _scrolledUnder = false; @override void didChangeDependencies() { super.didChangeDependencies(); - if (_scrollNotificationObserver != null) - _scrollNotificationObserver!.removeListener(_handleScrollNotification); - _scrollNotificationObserver = ScrollNotificationObserver.of(context); - if (_scrollNotificationObserver != null) - _scrollNotificationObserver!.addListener(_handleScrollNotification); + if (_scrollMetricsNotificationObserver != null) + _scrollMetricsNotificationObserver!.removeListener(_handleScrollMetricsNotification); + _scrollMetricsNotificationObserver = ScrollMetricsNotificationObserver.of(context); + if (_scrollMetricsNotificationObserver != null) + _scrollMetricsNotificationObserver!.addListener(_handleScrollMetricsNotification); } @override void dispose() { - if (_scrollNotificationObserver != null) { - _scrollNotificationObserver!.removeListener(_handleScrollNotification); - _scrollNotificationObserver = null; + if (_scrollMetricsNotificationObserver != null) { + _scrollMetricsNotificationObserver!.removeListener(_handleScrollMetricsNotification); + _scrollMetricsNotificationObserver = null; } super.dispose(); } @@ -767,18 +767,34 @@ class _AppBarState extends State { Scaffold.of(context).openEndDrawer(); } - void _handleScrollNotification(ScrollNotification notification) { - if (notification is ScrollUpdateNotification) { - final bool oldScrolledUnder = _scrolledUnder; - _scrolledUnder = notification.depth == 0 - && notification.metrics.extentBefore > 0 - && notification.metrics.axis == Axis.vertical; - if (_scrolledUnder != oldScrolledUnder) { - setState(() { - // React to a change in MaterialState.scrolledUnder - }); + void _handleScrollMetricsNotification(ScrollMetricsNotification notification) { + final bool oldScrolledUnder = _scrolledUnder; + final ScrollMetrics metrics = notification.metrics; + + if (notification.depth != 0) { + _scrolledUnder = false; + } else { + switch (metrics.axisDirection) { + case AxisDirection.up: + // Scroll view is reversed + _scrolledUnder = metrics.extentAfter > 0; + break; + case AxisDirection.down: + _scrolledUnder = metrics.extentBefore > 0; + break; + case AxisDirection.right: + case AxisDirection.left: + // Scrolled under is only supported in the vertical axis. + _scrolledUnder = false; + break; } } + + if (_scrolledUnder != oldScrolledUnder) { + setState(() { + // React to a change in MaterialState.scrolledUnder + }); + } } Color _resolveColor(Set states, Color? widgetColor, Color? themeColor, Color defaultColor) { diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index e5114c1da2..edd0b958df 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -3096,7 +3096,7 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto return _ScaffoldScope( hasDrawer: hasDrawer, geometryNotifier: _geometryNotifier, - child: ScrollNotificationObserver( + child: ScrollMetricsNotificationObserver( child: Material( color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, child: AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget? child) { diff --git a/packages/flutter/lib/src/widgets/scroll_notification_observer.dart b/packages/flutter/lib/src/widgets/scroll_notification_observer.dart index 7829cbd59e..bb51e1d58c 100644 --- a/packages/flutter/lib/src/widgets/scroll_notification_observer.dart +++ b/packages/flutter/lib/src/widgets/scroll_notification_observer.dart @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'framework.dart'; import 'notification_listener.dart'; import 'scroll_notification.dart'; +import 'scroll_position.dart'; /// A [ScrollNotification] listener for [ScrollNotificationObserver]. /// @@ -172,3 +173,170 @@ class ScrollNotificationObserverState extends State super.dispose(); } } + +/// A [ScrollMetricsNotification] listener for [ScrollMetricsNotificationObserver]. +/// +/// [ScrollMetricsNotificationObserver] is similar to +/// [NotificationListener]. It supports a listener list instead of +/// just a single listener and its listeners run unconditionally, they +/// do not require a gating boolean return value. +typedef ScrollMetricsNotificationCallback = void Function(ScrollMetricsNotification notification); + +class _ScrollMetricsNotificationObserverScope extends InheritedWidget { + const _ScrollMetricsNotificationObserverScope({ + Key? key, + required Widget child, + required ScrollMetricsNotificationObserverState scrollMetricsNotificationObserverState, + }) : _scrollMetricsNotificationObserverState = scrollMetricsNotificationObserverState, + super(key: key, child: child); + + final ScrollMetricsNotificationObserverState _scrollMetricsNotificationObserverState; + + @override + bool updateShouldNotify(_ScrollMetricsNotificationObserverScope old) { + return _scrollMetricsNotificationObserverState != old._scrollMetricsNotificationObserverState; + } +} + +class _MetricsListenerEntry extends LinkedListEntry<_MetricsListenerEntry> { + _MetricsListenerEntry(this.listener); + final ScrollMetricsNotificationCallback listener; +} + +/// Notifies its listeners when a descendant ScrollMetrics are +/// initialized or updated. +/// +/// To add a listener to a [ScrollMetricsNotificationObserver] ancestor: +/// ```dart +/// void listener(ScrollMetricsNotification notification) { +/// // Do something, maybe setState() +/// } +/// ScrollMetricsNotificationObserver.of(context).addListener(listener) +/// ``` +/// +/// To remove the listener from a [ScrollMetricsNotificationObserver] ancestor: +/// ```dart +/// ScrollMetricsNotificationObserver.of(context).removeListener(listener); +/// ``` +/// +/// Stateful widgets that share an ancestor [ScrollMetricsNotificationObserver] +/// typically add a listener in [State.didChangeDependencies] (removing the old +/// one if necessary) and remove the listener in their [State.dispose] method. +/// +/// This widget is similar to [NotificationListener]. It supports +/// a listener list instead of just a single listener and its listeners +/// run unconditionally, they do not require a gating boolean return value. +class ScrollMetricsNotificationObserver extends StatefulWidget { + /// Create a [ScrollMetricsNotificationObserver]. + /// + /// The [child] parameter must not be null. + const ScrollMetricsNotificationObserver({ + Key? key, + required this.child, + }) : assert(child != null), super(key: key); + + /// The subtree below this widget. + final Widget child; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [ScrollMetricsNotificationObserver] widget, then + /// null is returned. + static ScrollMetricsNotificationObserverState? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_ScrollMetricsNotificationObserverScope>()?._scrollMetricsNotificationObserverState; + } + + @override + ScrollMetricsNotificationObserverState createState() => ScrollMetricsNotificationObserverState(); +} + +/// The listener list state for a [ScrollMetricsNotificationObserver] returned +/// by [ScrollMetricsNotificationObserver.of]. +/// +/// [ScrollMetricsNotificationObserver] is similar to +/// [NotificationListener]. It supports a listener list instead of +/// just a single listener and its listeners run unconditionally, they +/// do not require a gating boolean return value. +class ScrollMetricsNotificationObserverState extends State { + LinkedList<_MetricsListenerEntry>? _listeners = LinkedList<_MetricsListenerEntry>(); + + bool _debugAssertNotDisposed() { + assert(() { + if (_listeners == null) { + throw FlutterError( + 'A $runtimeType was used after being disposed.\n' + 'Once you have called dispose() on a $runtimeType, it can no longer be used.', + ); + } + return true; + }()); + return true; + } + + /// Add a [ScrollMetricsNotificationCallback] that will be called each time + /// a descendant scrolls. + void addListener(ScrollMetricsNotificationCallback listener) { + assert(_debugAssertNotDisposed()); + _listeners!.add(_MetricsListenerEntry(listener)); + } + + /// Remove the specified [ScrollMetricsNotificationCallback]. + void removeListener(ScrollMetricsNotificationCallback listener) { + assert(_debugAssertNotDisposed()); + for (final _MetricsListenerEntry entry in _listeners!) { + if (entry.listener == listener) { + entry.unlink(); + return; + } + } + } + + void _notifyListeners(ScrollMetricsNotification notification) { + assert(_debugAssertNotDisposed()); + if (_listeners!.isEmpty) + return; + + final List<_MetricsListenerEntry> localListeners = List<_MetricsListenerEntry>.of(_listeners!); + for (final _MetricsListenerEntry entry in localListeners) { + try { + if (entry.list != null) + entry.listener(notification); + } catch (exception, stack) { + FlutterError.reportError(FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widget library', + context: ErrorDescription('while dispatching notifications for $runtimeType'), + informationCollector: () => [ + DiagnosticsProperty( + 'The $runtimeType sending notification was', + this, + style: DiagnosticsTreeStyle.errorProperty, + ), + ], + )); + } + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (ScrollMetricsNotification notification) { + _notifyListeners(notification); + return false; + }, + child: _ScrollMetricsNotificationObserverScope( + scrollMetricsNotificationObserverState: this, + child: widget.child, + ), + ); + } + + @override + void dispose() { + assert(_debugAssertNotDisposed()); + _listeners = null; + super.dispose(); + } +} diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index 3f78736f69..2c95313f78 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -2568,311 +2568,457 @@ void main() { expect(actionIconTheme.color, foregroundColor); }); - testWidgets('SliverAppBar.backgroundColor MaterialStateColor scrolledUnder', (WidgetTester tester) async { + group('MaterialStateColor scrolledUnder', () { const double collapsedHeight = kToolbarHeight; const double expandedHeight = 200.0; const Color scrolledColor = Color(0xff00ff00); const Color defaultColor = Color(0xff0000ff); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - elevation: 0, - backgroundColor: MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.scrolledUnder) ? scrolledColor : defaultColor; - }), - expandedHeight: expandedHeight, - pinned: true, - ), - SliverList( - delegate: SliverChildListDelegate( - [ - Container(height: 1200.0, color: Colors.teal), - ], - ), - ), - ], - ), - ), - ), - ); - Finder findAppBarMaterial() { - return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)); - } - - Color? getAppBarBackgroundColor() { - return tester.widget(findAppBarMaterial()).color; - } - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); - - TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(0.0, -expandedHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), scrolledColor); - expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); - - gesture = await tester.startGesture(const Offset(50.0, 300.0)); - await gesture.moveBy(const Offset(0.0, expandedHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); - }); - - testWidgets('SliverAppBar.backgroundColor with FlexibleSpace MaterialStateColor scrolledUnder', (WidgetTester tester) async { - const double collapsedHeight = kToolbarHeight; - const double expandedHeight = 200.0; - const Color scrolledColor = Color(0xff00ff00); - const Color defaultColor = Color(0xff0000ff); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - elevation: 0, - backgroundColor: MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.scrolledUnder) ? scrolledColor : defaultColor; - }), - expandedHeight: expandedHeight, - pinned: true, - flexibleSpace: const FlexibleSpaceBar( - title: Text('SliverAppBar'), - ), - ), - SliverList( - delegate: SliverChildListDelegate( - [ - Container(height: 1200.0, color: Colors.teal), - ], - ), - ), - ], - ), - ), - ), - ); - - Finder findAppBarMaterial() { - // There are 2 Material widgets below AppBar. The second is only added if - // flexibleSpace is non-null. return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)).first; } - Color? getAppBarBackgroundColor() { + Color? getAppBarBackgroundColor(WidgetTester tester) { return tester.widget(findAppBarMaterial()).color; } - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); - - TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(0.0, -expandedHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), scrolledColor); - expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); - - gesture = await tester.startGesture(const Offset(50.0, 300.0)); - await gesture.moveBy(const Offset(0.0, expandedHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); - }); - - testWidgets('AppBar.backgroundColor MaterialStateColor scrolledUnder', (WidgetTester tester) async { - const Color scrolledColor = Color(0xff00ff00); - const Color defaultColor = Color(0xff0000ff); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.scrolledUnder) ? scrolledColor : defaultColor; - }), - title: const Text('AppBar'), - ), - body: ListView( - children: [ - Container(height: 1200.0, color: Colors.teal), - ], - ), - ), - ), - ); - - Finder findAppBarMaterial() { - return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)); - } - - Color? getAppBarBackgroundColor() { - return tester.widget(findAppBarMaterial()).color; - } - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - - TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), scrolledColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - - gesture = await tester.startGesture(const Offset(50.0, 300.0)); - await gesture.moveBy(const Offset(0.0, kToolbarHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - }); - - testWidgets('AppBar.backgroundColor with FlexibleSpace MaterialStateColor scrolledUnder', (WidgetTester tester) async { - const Color scrolledColor = Color(0xff00ff00); - const Color defaultColor = Color(0xff0000ff); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.scrolledUnder) ? scrolledColor : defaultColor; - }), - title: const Text('AppBar'), - flexibleSpace: const FlexibleSpaceBar( - title: Text('FlexibleSpace'), - ), - ), - body: ListView( - children: [ - Container(height: 1200.0, color: Colors.teal), - ], - ), - ), - ), - ); - - Finder findAppBarMaterial() { - // There are 2 Material widgets below AppBar. The second is only added if - // flexibleSpace is non-null. - return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)).first; - } - - Color? getAppBarBackgroundColor() { - return tester.widget(findAppBarMaterial()).color; - } - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - - TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), scrolledColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - - gesture = await tester.startGesture(const Offset(50.0, 300.0)); - await gesture.moveBy(const Offset(0.0, kToolbarHeight)); - await gesture.up(); - await tester.pumpAndSettle(); - - expect(getAppBarBackgroundColor(), defaultColor); - expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); - }); - - testWidgets('AppBar._handleScrollNotification safely calls setState()', (WidgetTester tester) async { - // Regression test for failures found in Google internal issue b/185192049. - final ScrollController controller = ScrollController(initialScrollOffset: 400); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('AppBar'), - ), - body: Scrollbar( - isAlwaysShown: true, - controller: controller, - child: ListView( - controller: controller, - children: [ - Container(height: 1200.0, color: Colors.teal), + group('SliverAppBar', () { + Widget _buildSliverApp({ + required double contentHeight, + bool reverse = false, + bool includeFlexibleSpace = false, + }) { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + reverse: reverse, + slivers: [ + SliverAppBar( + elevation: 0, + backgroundColor: MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.scrolledUnder) + ? scrolledColor + : defaultColor; + }), + expandedHeight: expandedHeight, + pinned: true, + flexibleSpace: includeFlexibleSpace + ? const FlexibleSpaceBar(title: Text('SliverAppBar')) + : null, + ), + SliverList( + delegate: SliverChildListDelegate( + [ + Container(height: contentHeight, color: Colors.teal), + ], + ), + ), ], ), ), - ), - ), - ); + ); + } - expect(tester.takeException(), isNull); - }); + testWidgets('backgroundColor', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp(contentHeight: 1200.0) + ); - testWidgets('AppBar scrolledUnder does not trigger on horizontal scroll', (WidgetTester tester) async { - const Color scrolledColor = Color(0xff00ff00); - const Color defaultColor = Color(0xff0000ff); + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: AppBar( - elevation: 0, - backgroundColor: MaterialStateColor.resolveWith((Set states) { - return states.contains(MaterialState.scrolledUnder) ? scrolledColor : defaultColor; - }), - title: const Text('AppBar'), + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp(contentHeight: 1200.0, includeFlexibleSpace: true) + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp(contentHeight: 1200.0, reverse: true) + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp( + contentHeight: 1200.0, + reverse: true, + includeFlexibleSpace: true, + ) + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, collapsedHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor - not triggered in reverse for short content', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp(contentHeight: 200, reverse: true) + ); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - not triggered in reverse for short content', (WidgetTester tester) async { + await tester.pumpWidget( + _buildSliverApp( + contentHeight: 200, + reverse: true, + includeFlexibleSpace: true, + ) + ); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, expandedHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, expandedHeight); + }); + }); + + group('AppBar', () { + Widget _buildAppBar({ + required double contentHeight, + bool reverse = false, + bool includeFlexibleSpace = false + }) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.scrolledUnder) + ? scrolledColor + : defaultColor; + }), + title: const Text('AppBar'), + flexibleSpace: includeFlexibleSpace + ? const FlexibleSpaceBar(title: Text('FlexibleSpace')) + : null, + ), + body: ListView( + reverse: reverse, + children: [ + Container(height: contentHeight, color: Colors.teal), + ], + ), ), - body: ListView( - scrollDirection: Axis.horizontal, - children: [ - Container(height: 600.0, width: 1200.0, color: Colors.teal), - ], + ); + } + + testWidgets('backgroundColor', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar(contentHeight: 1200.0) + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar(contentHeight: 1200.0, includeFlexibleSpace: true) + ); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar(contentHeight: 1200.0, reverse: true) + ); + await tester.pump(); + + // In this test case, the content always extends under the AppBar, so it + // should always be the scrolledColor. + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - reverse', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar( + contentHeight: 1200.0, + reverse: true, + includeFlexibleSpace: true, + ) + ); + await tester.pump(); + + // In this test case, the content always extends under the AppBar, so it + // should always be the scrolledColor. + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + gesture = await tester.startGesture(const Offset(50.0, 300.0)); + await gesture.moveBy(const Offset(0.0, -kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), scrolledColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('_handleScrollMetricsNotification safely calls setState()', (WidgetTester tester) async { + // Regression test for failures found in Google internal issue b/185192049. + final ScrollController controller = ScrollController(initialScrollOffset: 400); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('AppBar'), + ), + body: Scrollbar( + isAlwaysShown: true, + controller: controller, + child: ListView( + controller: controller, + children: [ + Container(height: 1200.0, color: Colors.teal), + ], + ), + ), + ), ), - ), - ), - ); + ); - Finder findAppBarMaterial() { - return find.descendant(of: find.byType(AppBar), matching: find.byType(Material)); - } + expect(tester.takeException(), isNull); + }); - Color? getAppBarBackgroundColor() { - return tester.widget(findAppBarMaterial()).color; - } + testWidgets('does not trigger on horizontal scroll', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: MaterialStateColor.resolveWith((Set states) { + return states.contains(MaterialState.scrolledUnder) + ? scrolledColor + : defaultColor; + }), + title: const Text('AppBar'), + ), + body: ListView( + scrollDirection: Axis.horizontal, + children: [ + Container(height: 600.0, width: 1200.0, color: Colors.teal), + ], + ), + ), + ), + ); - expect(getAppBarBackgroundColor(), defaultColor); + expect(getAppBarBackgroundColor(tester), defaultColor); - TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(-100.0, 0.0)); - await gesture.up(); - await tester.pumpAndSettle(); + TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(-100.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); - expect(getAppBarBackgroundColor(), defaultColor); + expect(getAppBarBackgroundColor(tester), defaultColor); - gesture = await tester.startGesture(const Offset(50.0, 400.0)); - await gesture.moveBy(const Offset(100.0, 0.0)); - await gesture.up(); - await tester.pumpAndSettle(); + gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(100.0, 0.0)); + await gesture.up(); + await tester.pumpAndSettle(); - expect(getAppBarBackgroundColor(), defaultColor); + expect(getAppBarBackgroundColor(tester), defaultColor); + }); + + testWidgets('backgroundColor - not triggered in reverse for short content', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar( + contentHeight: 200.0, + reverse: true, + ) + ); + await tester.pump(); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + + testWidgets('backgroundColor with FlexibleSpace - not triggered in reverse for short content', (WidgetTester tester) async { + await tester.pumpWidget( + _buildAppBar( + contentHeight: 200.0, + reverse: true, + includeFlexibleSpace: true, + ) + ); + await tester.pump(); + + // In reverse, the content here is not long enough to scroll under the app + // bar. + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + + final TestGesture gesture = await tester.startGesture(const Offset(50.0, 400.0)); + await gesture.moveBy(const Offset(0.0, kToolbarHeight)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(getAppBarBackgroundColor(tester), defaultColor); + expect(tester.getSize(findAppBarMaterial()).height, kToolbarHeight); + }); + }); }); testWidgets('AppBar.preferredHeightFor', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 9cb32cdd55..759853ef02 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -2302,9 +2302,9 @@ void main() { ' PhysicalModel\n' ' AnimatedPhysicalModel\n' ' Material\n' - ' _ScrollNotificationObserverScope\n' - ' NotificationListener\n' - ' ScrollNotificationObserver\n' + ' _ScrollMetricsNotificationObserverScope\n' + ' NotificationListener\n' + ' ScrollMetricsNotificationObserver\n' ' _ScaffoldScope\n' ' Scaffold\n' ' MediaQuery\n'