diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 11833819d7..accbd5940e 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -276,21 +276,14 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { Rect? _trackRect; late double _thumbOffset; - /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will - /// show and redraw itself based on these new metrics. + /// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself + /// based on these new metrics. /// /// The scrollbar will remain on screen. void update( ScrollMetrics metrics, AxisDirection axisDirection, ) { - if (_lastMetrics != null && - _lastMetrics!.extentBefore == metrics.extentBefore && - _lastMetrics!.extentInside == metrics.extentInside && - _lastMetrics!.extentAfter == metrics.extentAfter && - _lastAxisDirection == axisDirection) - return; - _lastMetrics = metrics; _lastAxisDirection = axisDirection; notifyListeners(); @@ -937,90 +930,90 @@ class RawScrollbarState extends State with TickerProv @override void didChangeDependencies() { super.didChangeDependencies(); - _maybeRequestEmptyScrollEvent(); + _maybeTriggerScrollbar(); } // Waits one frame and cause an empty scroll event (zero delta pixels). // // This allows the thumb to show immediately when isAlwaysShown is true. // A scroll event is required in order to paint the thumb. - void _maybeRequestEmptyScrollEvent() { - if (!showScrollbar) - return; + void _maybeTriggerScrollbar() { WidgetsBinding.instance!.addPostFrameCallback((Duration duration) { - _fadeoutTimer?.cancel(); - // Wait one frame and cause an empty scroll event. This allows the - // thumb to show immediately when isAlwaysShown is true. A scroll - // event is required in order to paint the thumb. - final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.of(context); - final bool tryPrimary = widget.controller == null; - final String controllerForError = tryPrimary - ? 'provided ScrollController' - : 'PrimaryScrollController'; - assert( - scrollController != null, - 'A ScrollController is required when Scrollbar.isAlwaysShown is true. ' - '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, ' - 'and attempted to use the PrimaryScrollController, but none was found.' :''}', - ); - assert (() { - if (!scrollController!.hasClients) { - throw FlutterError.fromParts([ - ErrorSummary( - 'The Scrollbar\'s ScrollController has no ScrollPosition attached.', - ), - ErrorDescription( - 'A Scrollbar cannot be painted without a ScrollPosition. ', - ), - ErrorHint( - 'The Scrollbar attempted to use the $controllerForError. This ' - 'ScrollController should be associated with the ScrollView that ' - 'the Scrollbar is being applied to. ' - '${tryPrimary - ? 'A ScrollView with an Axis.vertical ' - 'ScrollDirection will automatically use the ' - 'PrimaryScrollController if the user has not provided a ' - 'ScrollController, but a ScrollDirection of Axis.horizontal will ' - 'not. To use the PrimaryScrollController explicitly, set ScrollView.primary ' - 'to true for the Scrollable widget.' - : 'When providing your own ScrollController, ensure both the ' - 'Scrollbar and the Scrollable widget use the same one.' - }', - ), - ]); - } - return true; - }()); - assert (() { - try { - scrollController!.position; - } catch (_) { - throw FlutterError.fromParts([ - ErrorSummary( - 'The $controllerForError is currently attached to more than one ' - 'ScrollPosition.', - ), - ErrorDescription( - 'The Scrollbar requires a single ScrollPosition in order to be painted.', - ), - ErrorHint( - 'When Scrollbar.isAlwaysShown is true, the associated Scrollable ' - 'widgets must have unique ScrollControllers. ' - '${tryPrimary - ? 'The PrimaryScrollController is used by default for ' - 'ScrollViews with an Axis.vertical ScrollDirection, ' - 'unless the ScrollView has been provided its own ' - 'ScrollController. More than one Scrollable may have tried ' - 'to use the PrimaryScrollController of the current context.' - : 'The provided ScrollController must be unique to a ' - 'Scrollable widget.' - }', - ), - ]); - } - return true; - }()); - scrollController!.position.didUpdateScrollPositionBy(0); + if (showScrollbar) { + _fadeoutTimer?.cancel(); + // Wait one frame and cause an empty scroll event. This allows the + // thumb to show immediately when isAlwaysShown is true. A scroll + // event is required in order to paint the thumb. + final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.of(context); + final bool tryPrimary = widget.controller == null; + final String controllerForError = tryPrimary + ? 'provided ScrollController' + : 'PrimaryScrollController'; + assert( + scrollController != null, + 'A ScrollController is required when Scrollbar.isAlwaysShown is true. ' + '${tryPrimary ? 'The Scrollbar was not provided a ScrollController, ' + 'and attempted to use the PrimaryScrollController, but none was found.' :''}', + ); + assert (() { + if (!scrollController!.hasClients) { + throw FlutterError.fromParts([ + ErrorSummary( + 'The Scrollbar\'s ScrollController has no ScrollPosition attached.', + ), + ErrorDescription( + 'A Scrollbar cannot be painted without a ScrollPosition. ', + ), + ErrorHint( + 'The Scrollbar attempted to use the $controllerForError. This ' + 'ScrollController should be associated with the ScrollView that ' + 'the Scrollbar is being applied to. ' + '${tryPrimary + ? 'A ScrollView with an Axis.vertical ' + 'ScrollDirection will automatically use the ' + 'PrimaryScrollController if the user has not provided a ' + 'ScrollController, but a ScrollDirection of Axis.horizontal will ' + 'not. To use the PrimaryScrollController explicitly, set ScrollView.primary ' + 'to true for the Scrollable widget.' + : 'When providing your own ScrollController, ensure both the ' + 'Scrollbar and the Scrollable widget use the same one.' + }', + ), + ]); + } + return true; + }()); + assert (() { + try { + scrollController!.position; + } catch (_) { + throw FlutterError.fromParts([ + ErrorSummary( + 'The $controllerForError is currently attached to more than one ' + 'ScrollPosition.', + ), + ErrorDescription( + 'The Scrollbar requires a single ScrollPosition in order to be painted.', + ), + ErrorHint( + 'When Scrollbar.isAlwaysShown is true, the associated Scrollable ' + 'widgets must have unique ScrollControllers. ' + '${tryPrimary + ? 'The PrimaryScrollController is used by default for ' + 'ScrollViews with an Axis.vertical ScrollDirection, ' + 'unless the ScrollView has been provided its own ' + 'ScrollController. More than one Scrollable may have tried ' + 'to use the PrimaryScrollController of the current context.' + : 'The provided ScrollController must be unique to a ' + 'Scrollable widget.' + }', + ), + ]); + } + return true; + }()); + scrollController!.position.didUpdateScrollPositionBy(0); + } }); } @@ -1042,14 +1035,13 @@ class RawScrollbarState extends State with TickerProv @override void didUpdateWidget(T oldWidget) { super.didUpdateWidget(oldWidget); - // If `isAlwaysShown` is true and does not change, - // it may be necessary to trigger a scroll event to show or hide the bar when the - // scrollable widget viewport size changed. - if (widget.isAlwaysShown == true) { - _maybeRequestEmptyScrollEvent(); - _fadeoutAnimationController.animateTo(1.0); - } else if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { - _fadeoutAnimationController.reverse(); + if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { + if (widget.isAlwaysShown == true) { + _maybeTriggerScrollbar(); + _fadeoutAnimationController.animateTo(1.0); + } else { + _fadeoutAnimationController.reverse(); + } } } @@ -1211,19 +1203,13 @@ class RawScrollbarState extends State with TickerProv return false; final ScrollMetrics metrics = notification.metrics; - if (metrics.maxScrollExtent <= metrics.minScrollExtent) { - // Hide the bar when the Scrollable widget has no space to scroll. - if (_fadeoutAnimationController.status != AnimationStatus.dismissed - && _fadeoutAnimationController.status != AnimationStatus.reverse) - _fadeoutAnimationController.reverse(); + if (metrics.maxScrollExtent <= metrics.minScrollExtent) return false; - } if (notification is ScrollUpdateNotification || notification is OverscrollNotification) { // Any movements always makes the scrollbar start showing up. - if (_fadeoutAnimationController.status != AnimationStatus.forward - && _fadeoutAnimationController.status != AnimationStatus.completed) + if (_fadeoutAnimationController.status != AnimationStatus.forward) _fadeoutAnimationController.forward(); _fadeoutTimer?.cancel(); diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index 69cc063f96..b9ac65d271 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -1152,35 +1152,4 @@ void main() { ), ); }); - - testWidgets('The bar can show or hide when the viewport size change', (WidgetTester tester) async { - final ScrollController scrollController = ScrollController(); - Widget buildFrame(double height) { - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: const MediaQueryData(), - child: RawScrollbar( - controller: scrollController, - isAlwaysShown: true, - child: SingleChildScrollView( - controller: scrollController, - child: SizedBox(width: double.infinity, height: height) - ), - ), - ), - ); - } - await tester.pumpWidget(buildFrame(600.0)); - await tester.pumpAndSettle(); - expect(find.byType(RawScrollbar), isNot(paints..rect())); // Not shown. - - await tester.pumpWidget(buildFrame(600.1)); - await tester.pumpAndSettle(); - expect(find.byType(RawScrollbar), paints..rect()..rect()); // Show the bar. - - await tester.pumpWidget(buildFrame(600.0)); - await tester.pumpAndSettle(); - expect(find.byType(RawScrollbar), isNot(paints..rect())); // Hide the bar. - }); }