diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index ef48fbc0ad..ee37391d73 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -19,6 +19,7 @@ import 'icon_button.dart'; import 'icons.dart'; import 'material.dart'; import 'material_localizations.dart'; +import 'material_state.dart'; import 'scaffold.dart'; import 'tabs.dart'; import 'text_theme.dart'; @@ -414,6 +415,10 @@ class AppBar extends StatefulWidget implements PreferredSizeWidget { /// null, then [AppBar] uses the overall theme's [ColorScheme.primary] if the /// overall theme's brightness is [Brightness.light], and [ColorScheme.surface] /// if the overall theme's [brightness] is [Brightness.dark]. + /// + /// If this color is a [MaterialStateColor] it will be resolved against + /// [MaterialState.scrolledUnder] when the content of the app's + /// primary scrollable overlaps the app bar. /// {@endtemplate} /// /// See also: @@ -704,6 +709,28 @@ class _AppBarState extends State { static const double _defaultElevation = 4.0; static const Color _defaultShadowColor = Color(0xFF000000); + ScrollNotificationObserverState? _scrollNotificationObserver; + bool _scrolledUnder = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_scrollNotificationObserver != null) + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = ScrollNotificationObserver.of(context); + if (_scrollNotificationObserver != null) + _scrollNotificationObserver!.addListener(_handleScrollNotification); + } + + @override + void dispose() { + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver!.removeListener(_handleScrollNotification); + _scrollNotificationObserver = null; + } + super.dispose(); + } + void _handleDrawerButton() { Scaffold.of(context).openDrawer(); } @@ -712,6 +739,24 @@ 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; + if (_scrolledUnder != oldScrolledUnder) { + setState(() { + // React to a change in MaterialState.scrolledUnder + }); + } + } + } + + Color _resolveColor(Set states, Color? widgetColor, Color? themeColor, Color defaultColor) { + return MaterialStateProperty.resolveAs(widgetColor, states) + ?? MaterialStateProperty.resolveAs(themeColor, states) + ?? MaterialStateProperty.resolveAs(defaultColor, states); + } + SystemUiOverlayStyle _systemOverlayStyleForBrightness(Brightness brightness) { return brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark; } @@ -726,6 +771,11 @@ class _AppBarState extends State { final ScaffoldState? scaffold = Scaffold.maybeOf(context); final ModalRoute? parentRoute = ModalRoute.of(context); + final FlexibleSpaceBarSettings? settings = context.dependOnInheritedWidgetOfExactType(); + final Set states = { + if (settings?.isScrolledUnder ?? _scrolledUnder) MaterialState.scrolledUnder, + }; + final bool hasDrawer = scaffold?.hasDrawer ?? false; final bool hasEndDrawer = scaffold?.hasEndDrawer ?? false; final bool canPop = parentRoute?.canPop ?? false; @@ -738,9 +788,11 @@ class _AppBarState extends State { ? widget.backgroundColor ?? appBarTheme.backgroundColor ?? theme.primaryColor - : widget.backgroundColor - ?? appBarTheme.backgroundColor - ?? (colorScheme.brightness == Brightness.dark ? colorScheme.surface : colorScheme.primary); + : _resolveColor( + states, + widget.backgroundColor, + appBarTheme.backgroundColor, + colorScheme.brightness == Brightness.dark ? colorScheme.surface : colorScheme.primary); final Color foregroundColor = widget.foregroundColor ?? appBarTheme.foregroundColor @@ -1145,6 +1197,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { final double extraToolbarHeight = math.max(minExtent - _bottomHeight - topPadding - (toolbarHeight ?? kToolbarHeight), 0.0); final double visibleToolbarHeight = visibleMainHeight - _bottomHeight - extraToolbarHeight; + final bool isScrolledUnder = overlapsContent || (pinned && shrinkOffset > maxExtent - minExtent); final bool isPinnedWithOpacityFade = pinned && floating && bottom != null && extraToolbarHeight == 0.0; final double toolbarOpacity = !pinned || isPinnedWithOpacityFade ? (visibleToolbarHeight / (toolbarHeight ?? kToolbarHeight)).clamp(0.0, 1.0) @@ -1155,6 +1208,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { maxExtent: maxExtent, currentExtent: math.max(minExtent, maxExtent - shrinkOffset), toolbarOpacity: toolbarOpacity, + isScrolledUnder: isScrolledUnder, child: AppBar( leading: leading, automaticallyImplyLeading: automaticallyImplyLeading, @@ -1164,7 +1218,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { ? Semantics(child: flexibleSpace, header: true) : flexibleSpace, bottom: bottom, - elevation: forceElevated || overlapsContent || (pinned && shrinkOffset > maxExtent - minExtent) ? elevation : 0.0, + elevation: forceElevated || isScrolledUnder ? elevation : 0.0, shadowColor: shadowColor, backgroundColor: backgroundColor, foregroundColor: foregroundColor, diff --git a/packages/flutter/lib/src/material/flexible_space_bar.dart b/packages/flutter/lib/src/material/flexible_space_bar.dart index 5dffe23399..7f451451c3 100644 --- a/packages/flutter/lib/src/material/flexible_space_bar.dart +++ b/packages/flutter/lib/src/material/flexible_space_bar.dart @@ -210,8 +210,9 @@ class FlexibleSpaceBar extends StatefulWidget { /// height of the resulting [FlexibleSpaceBar] when fully expanded. /// `currentExtent` sets the scale of the [FlexibleSpaceBar.background] and /// [FlexibleSpaceBar.title] widgets of [FlexibleSpaceBar] upon - /// initialization. - /// + /// initialization. `scrolledUnder` is true if the the [FlexibleSpaceBar] + /// overlaps the app's primary scrollable, false if it does not, and null + /// if the caller has not determined as much. /// See also: /// /// * [FlexibleSpaceBarSettings] which creates a settings object that can be @@ -220,6 +221,7 @@ class FlexibleSpaceBar extends StatefulWidget { double? toolbarOpacity, double? minExtent, double? maxExtent, + bool? isScrolledUnder, required double currentExtent, required Widget child, }) { @@ -228,6 +230,7 @@ class FlexibleSpaceBar extends StatefulWidget { toolbarOpacity: toolbarOpacity ?? 1.0, minExtent: minExtent ?? currentExtent, maxExtent: maxExtent ?? currentExtent, + isScrolledUnder: isScrolledUnder, currentExtent: currentExtent, child: child, ); @@ -441,6 +444,7 @@ class FlexibleSpaceBarSettings extends InheritedWidget { required this.maxExtent, required this.currentExtent, required Widget child, + this.isScrolledUnder, }) : assert(toolbarOpacity != null), assert(minExtent != null && minExtent >= 0), assert(maxExtent != null && maxExtent >= 0), @@ -465,11 +469,23 @@ class FlexibleSpaceBarSettings extends InheritedWidget { /// these elements upon initialization. final double currentExtent; + /// True if the FlexibleSpaceBar overlaps the primary scrollable's contents. + /// + /// This value is used by the [AppBar] to resolve + /// [AppBar.backgroundColor] against [MaterialState.scrolledUnder], + /// i.e. to enable apps to specify different colors when content + /// has been scrolled up and behind the app bar. + /// + /// Null if the caller hasn't determined if the FlexibleSpaceBar + /// overlaps the primary scrollable's contents. + final bool? isScrolledUnder; + @override bool updateShouldNotify(FlexibleSpaceBarSettings oldWidget) { return toolbarOpacity != oldWidget.toolbarOpacity || minExtent != oldWidget.minExtent || maxExtent != oldWidget.maxExtent - || currentExtent != oldWidget.currentExtent; + || currentExtent != oldWidget.currentExtent + || isScrolledUnder != oldWidget.isScrolledUnder; } } diff --git a/packages/flutter/lib/src/material/material_state.dart b/packages/flutter/lib/src/material/material_state.dart index 73c661d193..b4808bb153 100644 --- a/packages/flutter/lib/src/material/material_state.dart +++ b/packages/flutter/lib/src/material/material_state.dart @@ -63,7 +63,13 @@ enum MaterialState { /// See: https://material.io/design/interaction/states.html#selected. selected, - /// The state when this widget disabled and can not be interacted with. + /// The state when this widget overlaps the content of a scrollable below. + /// + /// Used by [AppBar] to indicate that the primary scrollable's + /// content has scrolled up and behind the app bar. + scrolledUnder, + + /// The state when this widget is disabled and cannot be interacted with. /// /// Disabled widgets should not respond to hover, focus, press, or drag /// interactions. diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index e229694e08..c142a9217e 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -3238,27 +3238,29 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto return _ScaffoldScope( hasDrawer: hasDrawer, geometryNotifier: _geometryNotifier, - child: Material( - color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, - child: AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget? child) { - return CustomMultiChildLayout( - children: children, - delegate: _ScaffoldLayout( - extendBody: _extendBody, - extendBodyBehindAppBar: widget.extendBodyBehindAppBar, - minInsets: minInsets, - minViewPadding: minViewPadding, - currentFloatingActionButtonLocation: _floatingActionButtonLocation!, - floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value, - floatingActionButtonMotionAnimator: _floatingActionButtonAnimator, - geometryNotifier: _geometryNotifier, - previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation!, - textDirection: textDirection, - isSnackBarFloating: isSnackBarFloating, - snackBarWidth: snackBarWidth, - ), - ); - }), + child: ScrollNotificationObserver( + child: Material( + color: widget.backgroundColor ?? themeData.scaffoldBackgroundColor, + child: AnimatedBuilder(animation: _floatingActionButtonMoveController, builder: (BuildContext context, Widget? child) { + return CustomMultiChildLayout( + children: children, + delegate: _ScaffoldLayout( + extendBody: _extendBody, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + minInsets: minInsets, + minViewPadding: minViewPadding, + currentFloatingActionButtonLocation: _floatingActionButtonLocation!, + floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value, + floatingActionButtonMotionAnimator: _floatingActionButtonAnimator, + geometryNotifier: _geometryNotifier, + previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation!, + textDirection: textDirection, + isSnackBarFloating: isSnackBarFloating, + snackBarWidth: snackBarWidth, + ), + ); + }), + ), ), ); } diff --git a/packages/flutter/lib/src/widgets/scroll_notification_observer.dart b/packages/flutter/lib/src/widgets/scroll_notification_observer.dart new file mode 100644 index 0000000000..e2dd909bff --- /dev/null +++ b/packages/flutter/lib/src/widgets/scroll_notification_observer.dart @@ -0,0 +1,174 @@ +// 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:collection'; + +import 'package:flutter/foundation.dart'; + +import 'framework.dart'; +import 'notification_listener.dart'; +import 'scroll_notification.dart'; + +/// A [ScrollNotification] listener for [ScrollNotificationObserver]. +/// +/// [ScrollNotificationObserver] 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 ScrollNotificationCallback = void Function(ScrollNotification notification); + +class _ScrollNotificationObserverScope extends InheritedWidget { + const _ScrollNotificationObserverScope({ + Key? key, + required Widget child, + required ScrollNotificationObserverState scrollNotificationObserverState, + }) : _scrollNotificationObserverState = scrollNotificationObserverState, + super(key: key, child: child); + + final ScrollNotificationObserverState _scrollNotificationObserverState; + + @override + bool updateShouldNotify(_ScrollNotificationObserverScope old) => _scrollNotificationObserverState != old._scrollNotificationObserverState; +} + +class _ListenerEntry extends LinkedListEntry<_ListenerEntry> { + _ListenerEntry(this.listener); + final ScrollNotificationCallback listener; +} + +/// Notifies its listeners when a descendant scrolls. +/// +/// To add a listener to a [ScrollNotificationObserver] ancestor: +/// ```dart +/// void listener(ScrollNotification notification) { +/// // Do something, maybe setState() +/// } +/// ScrollNotificationObserver.of(context).addListener(listener) +/// ``` +/// +/// To remove the listener from a [ScrollNotificationObserver] ancestor: +/// ```dart +/// ScrollNotificationObserver.of(context).removeListener(listener); +///``` +/// +/// Stateful widgets that share an ancestor [ScrollNotificationObserver] 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 ScrollNotificationObserver extends StatefulWidget { + /// Create a [ScrollNotificationObserver]. + /// + /// The [child] parameter must not be null. + const ScrollNotificationObserver({ + 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 [ScrollNotificationObserver] widget, then null is returned. + static ScrollNotificationObserverState? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_ScrollNotificationObserverScope>()?._scrollNotificationObserverState; + } + + @override + ScrollNotificationObserverState createState() => ScrollNotificationObserverState(); +} + +/// The listener list state for a [ScrollNotificationObserver] returned by +/// [ScrollNotificationObserver.of]. +/// +/// [ScrollNotificationObserver] 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 ScrollNotificationObserverState extends State { + LinkedList<_ListenerEntry>? _listeners = LinkedList<_ListenerEntry>(); + + 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 [ScrollNotificationCallback] that will be called each time + /// a descendant scrolls. + void addListener(ScrollNotificationCallback listener) { + assert(_debugAssertNotDisposed()); + _listeners!.add(_ListenerEntry(listener)); + } + + /// Remove the specified [ScrollNotificationCallback]. + void removeListener(ScrollNotificationCallback listener) { + assert(_debugAssertNotDisposed()); + for (final _ListenerEntry entry in _listeners!) { + if (entry.listener == listener) { + entry.unlink(); + return; + } + } + } + + void _notifyListeners(ScrollNotification notification) { + assert(_debugAssertNotDisposed()); + if (_listeners!.isEmpty) + return; + + final List<_ListenerEntry> localListeners = List<_ListenerEntry>.from(_listeners!); + for (final _ListenerEntry 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: () sync* { + yield DiagnosticsProperty( + 'The $runtimeType sending notification was', + this, + style: DiagnosticsTreeStyle.errorProperty, + ); + }, + )); + } + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: (ScrollNotification notification) { + _notifyListeners(notification); + return false; + }, + child: _ScrollNotificationObserverScope( + scrollNotificationObserverState: this, + child: widget.child, + ), + ); + } + + @override + void dispose() { + assert(_debugAssertNotDisposed()); + _listeners = null; + super.dispose(); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 48716c7754..9e02df983b 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -98,6 +98,7 @@ export 'src/widgets/scroll_context.dart'; export 'src/widgets/scroll_controller.dart'; export 'src/widgets/scroll_metrics.dart'; export 'src/widgets/scroll_notification.dart'; +export 'src/widgets/scroll_notification_observer.dart'; export 'src/widgets/scroll_physics.dart'; export 'src/widgets/scroll_position.dart'; export 'src/widgets/scroll_position_with_single_context.dart'; diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index 1ff0b2e96f..02bee22c75 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -2560,4 +2560,266 @@ void main() { expect(actionIconTheme.color, foregroundColor); }); + testWidgets('SliverAppBar.backgroundColor 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( + backwardsCompatibility: false, + 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( + backwardsCompatibility: false, + 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() { + 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( + backwardsCompatibility: false, + 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( + backwardsCompatibility: false, + 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( + backwardsCompatibility: false, + title: const Text('AppBar'), + ), + body: Scrollbar( + isAlwaysShown: true, + controller: controller, + child: ListView( + controller: controller, + children: [ + Container(height: 1200.0, color: Colors.teal), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); } diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 4f2b0a15a6..f868339fd0 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -2190,6 +2190,9 @@ void main() { ' PhysicalModel\n' ' AnimatedPhysicalModel\n' ' Material\n' + ' _ScrollNotificationObserverScope\n' + ' NotificationListener\n' + ' ScrollNotificationObserver\n' ' _ScaffoldScope\n' ' Scaffold\n' ' MediaQuery\n' diff --git a/packages/flutter/test/widgets/scroll_notification_test.dart b/packages/flutter/test/widgets/scroll_notification_test.dart index 20d7077d38..f85eda9c60 100644 --- a/packages/flutter/test/widgets/scroll_notification_test.dart +++ b/packages/flutter/test/widgets/scroll_notification_test.dart @@ -150,4 +150,69 @@ void main() { expect(notificationTypes, equals(types)); }); + testWidgets('ScrollNotificationObserver', (WidgetTester tester) async { + late ScrollNotificationObserverState observer; + ScrollNotification? notification; + + void handleNotification(ScrollNotification value) { + if (value is ScrollStartNotification || value is ScrollUpdateNotification || value is ScrollEndNotification) + notification = value; + } + + await tester.pumpWidget( + ScrollNotificationObserver( + child: Builder( + builder: (BuildContext context) { + observer = ScrollNotificationObserver.of(context)!; + return const SingleChildScrollView( + child: SizedBox(height: 1200.0), + ); + }, + ), + ), + ); + + observer.addListener(handleNotification); + + TestGesture gesture = await tester.startGesture(const Offset(100.0, 100.0)); + await tester.pumpAndSettle(); + expect(notification, isA()); + expect(notification!.depth, equals(0)); + + final ScrollStartNotification start = notification! as ScrollStartNotification; + expect(start.dragDetails, isNotNull); + expect(start.dragDetails!.globalPosition, equals(const Offset(100.0, 100.0))); + + await gesture.moveBy(const Offset(-10.0, -10.0)); + await tester.pumpAndSettle(); + expect(notification, isA()); + expect(notification!.depth, equals(0)); + final ScrollUpdateNotification update = notification! as ScrollUpdateNotification; + expect(update.dragDetails, isNotNull); + expect(update.dragDetails!.globalPosition, equals(const Offset(90.0, 90.0))); + expect(update.dragDetails!.delta, equals(const Offset(0.0, -10.0))); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(notification, isA()); + expect(notification!.depth, equals(0)); + final ScrollEndNotification end = notification! as ScrollEndNotification; + expect(end.dragDetails, isNotNull); + expect(end.dragDetails!.velocity, equals(Velocity.zero)); + + observer.removeListener(handleNotification); + notification = null; + + gesture = await tester.startGesture(const Offset(100.0, 100.0)); + await tester.pumpAndSettle(); + expect(notification, isNull); + + await gesture.moveBy(const Offset(-10.0, -10.0)); + await tester.pumpAndSettle(); + expect(notification, isNull); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(notification, isNull); + }); }