diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 238bd0bfbf6..4a2aea20a60 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -1458,13 +1458,17 @@ class RawScrollbar extends StatefulWidget { /// scrollbar track. class RawScrollbarState extends State with TickerProviderStateMixin { Offset? _startDragScrollbarAxisOffset; + Offset? _lastDragUpdateOffset; double? _startDragThumbOffset; - ScrollController? _currentController; + ScrollController? _cachedController; Timer? _fadeoutTimer; late AnimationController _fadeoutAnimationController; late Animation _fadeoutOpacityAnimation; final GlobalKey _scrollbarPainterKey = GlobalKey(); bool _hoverIsActive = false; + bool _thumbDragging = false; + + ScrollController? get _effectiveScrollController => widget.controller ?? PrimaryScrollController.maybeOf(context); /// Used to paint the scrollbar. /// @@ -1550,12 +1554,11 @@ class RawScrollbarState extends State with TickerProv } void _validateInteractions(AnimationStatus status) { - final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context); if (status == AnimationStatus.dismissed) { assert(_fadeoutOpacityAnimation.value == 0.0); // We do not check for a valid scroll position if the scrollbar is not // visible, because it cannot be interacted with. - } else if (scrollController != null && enableGestures) { + } else if (_effectiveScrollController != null && enableGestures) { // Interactive scrollbars need to be properly configured. If it is visible // for interaction, ensure we are set up properly. assert(_debugCheckHasValidScrollPosition()); @@ -1566,7 +1569,7 @@ class RawScrollbarState extends State with TickerProv if (!mounted) { return true; } - final ScrollController? scrollController = widget.controller ?? PrimaryScrollController.maybeOf(context); + final ScrollController? scrollController = _effectiveScrollController; final bool tryPrimary = widget.controller == null; final String controllerForError = tryPrimary ? 'PrimaryScrollController' @@ -1698,11 +1701,11 @@ class RawScrollbarState extends State with TickerProv } void _updateScrollPosition(Offset updatedOffset) { - assert(_currentController != null); + assert(_cachedController != null); assert(_startDragScrollbarAxisOffset != null); assert(_startDragThumbOffset != null); - final ScrollPosition position = _currentController!.position; + final ScrollPosition position = _cachedController!.position; late double primaryDelta; switch (position.axisDirection) { case AxisDirection.up: @@ -1761,9 +1764,9 @@ class RawScrollbarState extends State with TickerProv /// current scroll controller does not have any attached positions. @protected Axis? getScrollbarDirection() { - assert(_currentController != null); - if (_currentController!.hasClients) { - return _currentController!.position.axis; + assert(_cachedController != null); + if (_cachedController!.hasClients) { + return _cachedController!.position.axis; } return null; } @@ -1788,7 +1791,7 @@ class RawScrollbarState extends State with TickerProv @mustCallSuper void handleThumbPressStart(Offset localPosition) { assert(_debugCheckHasValidScrollPosition()); - _currentController = widget.controller ?? PrimaryScrollController.maybeOf(context); + _cachedController = _effectiveScrollController; final Axis? direction = getScrollbarDirection(); if (direction == null) { return; @@ -1797,6 +1800,7 @@ class RawScrollbarState extends State with TickerProv _fadeoutAnimationController.forward(); _startDragScrollbarAxisOffset = localPosition; _startDragThumbOffset = scrollbarPainter.getThumbScrollOffset(); + _thumbDragging = true; } /// Handler called when a currently active long press gesture moves. @@ -1806,7 +1810,11 @@ class RawScrollbarState extends State with TickerProv @mustCallSuper void handleThumbPressUpdate(Offset localPosition) { assert(_debugCheckHasValidScrollPosition()); - final ScrollPosition position = _currentController!.position; + if (_lastDragUpdateOffset == localPosition) { + return; + } + _lastDragUpdateOffset = localPosition; + final ScrollPosition position = _cachedController!.position; if (!position.physics.shouldAcceptUserOffset(position)) { return; } @@ -1822,22 +1830,24 @@ class RawScrollbarState extends State with TickerProv @mustCallSuper void handleThumbPressEnd(Offset localPosition, Velocity velocity) { assert(_debugCheckHasValidScrollPosition()); + _thumbDragging = false; final Axis? direction = getScrollbarDirection(); if (direction == null) { return; } _maybeStartFadeoutTimer(); _startDragScrollbarAxisOffset = null; + _lastDragUpdateOffset = null; _startDragThumbOffset = null; - _currentController = null; + _cachedController = null; } void _handleTrackTapDown(TapDownDetails details) { // The Scrollbar should page towards the position of the tap on the track. assert(_debugCheckHasValidScrollPosition()); - _currentController = widget.controller ?? PrimaryScrollController.maybeOf(context); + _cachedController = _effectiveScrollController; - final ScrollPosition position = _currentController!.position; + final ScrollPosition position = _cachedController!.position; if (!position.physics.shouldAcceptUserOffset(position)) { return; } @@ -1845,22 +1855,22 @@ class RawScrollbarState extends State with TickerProv double scrollIncrement; // Is an increment calculator available? final ScrollIncrementCalculator? calculator = Scrollable.maybeOf( - _currentController!.position.context.notificationContext!, + _cachedController!.position.context.notificationContext!, )?.widget.incrementCalculator; if (calculator != null) { scrollIncrement = calculator( ScrollIncrementDetails( type: ScrollIncrementType.page, - metrics: _currentController!.position, + metrics: _cachedController!.position, ), ); } else { // Default page increment - scrollIncrement = 0.8 * _currentController!.position.viewportDimension; + scrollIncrement = 0.8 * _cachedController!.position.viewportDimension; } // Adjust scrollIncrement for direction - switch (_currentController!.position.axisDirection) { + switch (_cachedController!.position.axisDirection) { case AxisDirection.up: if (details.localPosition.dy > scrollbarPainter._thumbOffset) { scrollIncrement = -scrollIncrement; @@ -1883,8 +1893,8 @@ class RawScrollbarState extends State with TickerProv break; } - _currentController!.position.moveTo( - _currentController!.position.pixels + scrollIncrement, + _cachedController!.position.moveTo( + _cachedController!.position.pixels + scrollIncrement, duration: const Duration(milliseconds: 100), curve: Curves.easeInOut, ); @@ -1892,8 +1902,7 @@ class RawScrollbarState extends State with TickerProv // ScrollController takes precedence over ScrollNotification bool _shouldUpdatePainter(Axis notificationAxis) { - final ScrollController? scrollController = widget.controller ?? - PrimaryScrollController.maybeOf(context); + final ScrollController? scrollController = _effectiveScrollController; // Only update the painter of this scrollbar if the notification // metrics do not conflict with the information we have from the scroll // controller. @@ -1979,8 +1988,7 @@ class RawScrollbarState extends State with TickerProv Map get _gestures { final Map gestures = {}; - final ScrollController? controller = widget.controller ?? PrimaryScrollController.maybeOf(context); - if (controller == null || !enableGestures) { + if (_effectiveScrollController == null || !enableGestures) { return gestures; } @@ -2086,6 +2094,64 @@ class RawScrollbarState extends State with TickerProv _maybeStartFadeoutTimer(); } + // Returns the delta that should result from applying [event] with axis and + // direction taken into account. + double _pointerSignalEventDelta(PointerScrollEvent event) { + assert(_cachedController != null); + double delta = _cachedController!.position.axis == Axis.horizontal + ? event.scrollDelta.dx + : event.scrollDelta.dy; + + if (axisDirectionIsReversed(_cachedController!.position.axisDirection)) { + delta *= -1; + } + return delta; + } + + // Returns the offset that should result from applying [event] to the current + // position, taking min/max scroll extent into account. + double _targetScrollOffsetForPointerScroll(double delta) { + assert(_cachedController != null); + return math.min( + math.max(_cachedController!.position.pixels + delta, _cachedController!.position.minScrollExtent), + _cachedController!.position.maxScrollExtent, + ); + } + + void _handlePointerScroll(PointerEvent event) { + assert(event is PointerScrollEvent); + _cachedController = _effectiveScrollController; + final double delta = _pointerSignalEventDelta(event as PointerScrollEvent); + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); + if (delta != 0.0 && targetScrollOffset != _cachedController!.position.pixels) { + _cachedController!.position.pointerScroll(delta); + } + } + + void _receivedPointerSignal(PointerSignalEvent event) { + _cachedController = _effectiveScrollController; + // Only try to scroll if the bar absorb the hit test. + if ((scrollbarPainter.hitTest(event.localPosition) ?? false) && + _cachedController != null && + _cachedController!.hasClients && + (!_thumbDragging || kIsWeb)) { + final ScrollPosition position = _cachedController!.position; + if (event is PointerScrollEvent && position != null) { + if (!position.physics.shouldAcceptUserOffset(position)) { + return; + } + final double delta = _pointerSignalEventDelta(event); + final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); + if (delta != 0.0 && targetScrollOffset != position.pixels) { + GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll); + } + } else if (event is PointerScrollInertiaCancelEvent) { + position.jumpTo(position.pixels); + // Don't use the pointer signal resolver, all hit-tested scrollables should stop. + } + } + } + @override void dispose() { _fadeoutAnimationController.dispose(); @@ -2103,43 +2169,46 @@ class RawScrollbarState extends State with TickerProv child: NotificationListener( onNotification: _handleScrollNotification, child: RepaintBoundary( - child: RawGestureDetector( - gestures: _gestures, - child: MouseRegion( - onExit: (PointerExitEvent event) { - switch(event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - if (enableGestures) { - handleHoverExit(event); - } - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - onHover: (PointerHoverEvent event) { - switch(event.kind) { - case PointerDeviceKind.mouse: - case PointerDeviceKind.trackpad: - if (enableGestures) { - handleHover(event); - } - break; - case PointerDeviceKind.stylus: - case PointerDeviceKind.invertedStylus: - case PointerDeviceKind.unknown: - case PointerDeviceKind.touch: - break; - } - }, - child: CustomPaint( - key: _scrollbarPainterKey, - foregroundPainter: scrollbarPainter, - child: RepaintBoundary(child: widget.child), + child: Listener( + onPointerSignal: _receivedPointerSignal, + child: RawGestureDetector( + gestures: _gestures, + child: MouseRegion( + onExit: (PointerExitEvent event) { + switch(event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + if (enableGestures) { + handleHoverExit(event); + } + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + onHover: (PointerHoverEvent event) { + switch(event.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + if (enableGestures) { + handleHover(event); + } + break; + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.unknown: + case PointerDeviceKind.touch: + break; + } + }, + child: CustomPaint( + key: _scrollbarPainterKey, + foregroundPainter: scrollbarPainter, + child: RepaintBoundary(child: widget.child), + ), ), ), ), diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index d5631dafd9c..e0b8fad3018 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -1200,25 +1200,32 @@ void main() { pointer.hover(const Offset(793.0, 15.0)); await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0))); await tester.pumpAndSettle(); - // Scrolling while holding the drag on the scrollbar and still hovered over - // the scrollbar should not have changed the scroll offset. - expect(pointer.location, const Offset(793.0, 15.0)); - expect(scrollController.offset, previousOffset); - expect( - find.byType(CupertinoScrollbar), - paints..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1), - const Radius.circular(4.0), + + if (!kIsWeb) { + // Scrolling while holding the drag on the scrollbar and still hovered over + // the scrollbar should not have changed the scroll offset. + expect(pointer.location, const Offset(793.0, 15.0)); + expect(scrollController.offset, previousOffset); + expect( + find.byType(CupertinoScrollbar), + paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(789.0, 13.0, 797.0, 102.1), + const Radius.circular(4.0), + ), + color: _kScrollbarColor.color, ), - color: _kScrollbarColor.color, - ), - ); + ); + } else { + expect(pointer.location, const Offset(793.0, 15.0)); + expect(scrollController.offset, previousOffset + 20.0); + } + // Drag is still being held, move pointer to be hovering over another area // of the scrollable (not over the scrollbar) and execute another pointer scroll pointer.hover(tester.getCenter(find.byType(SingleChildScrollView))); - await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0))); await tester.pumpAndSettle(); // Scrolling while holding the drag on the scrollbar changed the offset expect(pointer.location, const Offset(400.0, 300.0)); diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index f5cf45a456f..ff114c61b3f 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -1634,33 +1634,39 @@ void main() { pointer.hover(const Offset(798.0, 15.0)); await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0))); await tester.pumpAndSettle(); - // Scrolling while holding the drag on the scrollbar and still hovered over - // the scrollbar should not have changed the scroll offset. - expect(pointer.location, const Offset(798.0, 15.0)); - expect(scrollController.offset, previousOffset); - expect( - find.byType(Scrollbar), - paints - ..rect( - rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), - color: Colors.transparent, - ) - ..line( - p1: const Offset(796.0, 0.0), - p2: const Offset(796.0, 600.0), - strokeWidth: 1.0, - color: Colors.transparent, - ) - ..rect( - rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), - color: const Color(0x99000000), - ), - ); + + if (!kIsWeb) { + // Scrolling while holding the drag on the scrollbar and still hovered over + // the scrollbar should not have changed the scroll offset. + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset); + expect( + find.byType(Scrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(796.0, 0.0, 800.0, 600.0), + color: Colors.transparent, + ) + ..line( + p1: const Offset(796.0, 0.0), + p2: const Offset(796.0, 600.0), + strokeWidth: 1.0, + color: Colors.transparent, + ) + ..rect( + rect: const Rect.fromLTRB(796.0, 10.0, 800.0, 100.0), + color: const Color(0x99000000), + ), + ); + } else { + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset + 20.0); + } // Drag is still being held, move pointer to be hovering over another area // of the scrollable (not over the scrollbar) and execute another pointer scroll pointer.hover(tester.getCenter(find.byType(SingleChildScrollView))); - await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0))); await tester.pumpAndSettle(); // Scrolling while holding the drag on the scrollbar changed the offset expect(pointer.location, const Offset(400.0, 300.0)); diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index e509f6927b1..0d4881afb17 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -1607,33 +1607,39 @@ void main() { pointer.hover(const Offset(798.0, 15.0)); await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 20.0))); await tester.pumpAndSettle(); - // Scrolling while holding the drag on the scrollbar and still hovered over - // the scrollbar should not have changed the scroll offset. - expect(pointer.location, const Offset(798.0, 15.0)); - expect(scrollController.offset, previousOffset); - expect( - find.byType(RawScrollbar), - paints - ..rect( - rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0), - color: const Color(0x00000000), - ) - ..line( - p1: const Offset(794.0, 0.0), - p2: const Offset(794.0, 600.0), - strokeWidth: 1.0, - color: const Color(0x00000000), - ) - ..rect( - rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0), - color: const Color(0x66bcbcbc), - ), - ); + + if (!kIsWeb) { + // Scrolling while holding the drag on the scrollbar and still hovered over + // the scrollbar should not have changed the scroll offset. + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset); + expect( + find.byType(RawScrollbar), + paints + ..rect( + rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0), + color: const Color(0x00000000), + ) + ..line( + p1: const Offset(794.0, 0.0), + p2: const Offset(794.0, 600.0), + strokeWidth: 1.0, + color: const Color(0x00000000), + ) + ..rect( + rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0), + color: const Color(0x66bcbcbc), + ), + ); + } else { + expect(pointer.location, const Offset(798.0, 15.0)); + expect(scrollController.offset, previousOffset + 20.0); + } // Drag is still being held, move pointer to be hovering over another area // of the scrollable (not over the scrollbar) and execute another pointer scroll pointer.hover(tester.getCenter(find.byType(SingleChildScrollView))); - await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -70.0))); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, -90.0))); await tester.pumpAndSettle(); // Scrolling while holding the drag on the scrollbar changed the offset expect(pointer.location, const Offset(400.0, 300.0)); @@ -2788,4 +2794,50 @@ void main() { ), ); }); + + testWidgets('The bar support mouse wheel event', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/pull/109659 + final ScrollController scrollController = ScrollController(); + Widget buildFrame() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: RawScrollbar( + thumbVisibility: true, + controller: scrollController, + child: const SingleChildScrollView( + primary: true, + child: SizedBox( + width: double.infinity, + height: 1200.0, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + + // Execute a pointer scroll hover on the scroll bar + final TestPointer pointer = TestPointer(1, ui.PointerDeviceKind.mouse); + pointer.hover(const Offset(798.0, 15.0)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 30.0))); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 30.0); + + // Execute a pointer scroll outside the scroll bar + pointer.hover(const Offset(198.0, 15.0)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 70.0))); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 100.0); + }, variant: TargetPlatformVariant.all()); }