diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 459d1a95e7..a64c98680d 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -951,7 +951,7 @@ class RawScrollbar extends StatefulWidget { /// Provides defaults gestures for dragging the scrollbar thumb and tapping on the /// scrollbar track. class RawScrollbarState extends State with TickerProviderStateMixin { - double? _dragScrollbarAxisPosition; + Offset? _dragScrollbarAxisOffset; ScrollController? _currentController; Timer? _fadeoutTimer; late AnimationController _fadeoutAnimationController; @@ -1133,9 +1133,25 @@ class RawScrollbarState extends State with TickerProv } } - void _updateScrollPosition(double primaryDelta) { + void _updateScrollPosition(Offset updatedOffset) { assert(_currentController != null); + assert(_dragScrollbarAxisOffset != null); final ScrollPosition position = _currentController!.position; + late double primaryDelta; + switch (position.axisDirection) { + case AxisDirection.up: + primaryDelta = _dragScrollbarAxisOffset!.dy - updatedOffset.dy; + break; + case AxisDirection.right: + primaryDelta = updatedOffset.dx -_dragScrollbarAxisOffset!.dx; + break; + case AxisDirection.down: + primaryDelta = updatedOffset.dy -_dragScrollbarAxisOffset!.dy; + break; + case AxisDirection.left: + primaryDelta = _dragScrollbarAxisOffset!.dx - updatedOffset.dx; + break; + } // Convert primaryDelta, the amount that the scrollbar moved since the last // time _updateScrollPosition was called, into the coordinate space of the scroll @@ -1159,8 +1175,8 @@ class RawScrollbarState extends State with TickerProv } } - /// Returns the [Axis] of the child scroll view, or null if the current scroll - /// controller does not have any attached positions. + /// Returns the [Axis] of the child scroll view, or null if the + /// current scroll controller does not have any attached positions. @protected Axis? getScrollbarDirection() { assert(_currentController != null); @@ -1194,14 +1210,7 @@ class RawScrollbarState extends State with TickerProv } _fadeoutTimer?.cancel(); _fadeoutAnimationController.forward(); - switch (direction) { - case Axis.vertical: - _dragScrollbarAxisPosition = localPosition.dy; - break; - case Axis.horizontal: - _dragScrollbarAxisPosition = localPosition.dx; - break; - } + _dragScrollbarAxisOffset = localPosition; } /// Handler called when a currently active long press gesture moves. @@ -1214,16 +1223,8 @@ class RawScrollbarState extends State with TickerProv if (direction == null) { return; } - switch(direction) { - case Axis.vertical: - _updateScrollPosition(localPosition.dy - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = localPosition.dy; - break; - case Axis.horizontal: - _updateScrollPosition(localPosition.dx - _dragScrollbarAxisPosition!); - _dragScrollbarAxisPosition = localPosition.dx; - break; - } + _updateScrollPosition(localPosition); + _dragScrollbarAxisOffset = localPosition; } /// Handler called when a long press has ended. @@ -1234,7 +1235,7 @@ class RawScrollbarState extends State with TickerProv if (direction == null) return; _maybeStartFadeoutTimer(); - _dragScrollbarAxisPosition = null; + _dragScrollbarAxisOffset = null; _currentController = null; } @@ -1303,7 +1304,7 @@ class RawScrollbarState extends State with TickerProv _fadeoutTimer?.cancel(); scrollbarPainter.update(notification.metrics, notification.metrics.axisDirection); } else if (notification is ScrollEndNotification) { - if (_dragScrollbarAxisPosition == null) + if (_dragScrollbarAxisOffset == null) _maybeStartFadeoutTimer(); } return false; diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index 47afc01845..bbedb7290c 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -167,6 +167,80 @@ void main() { await tester.pump(_kScrollbarFadeDuration); }); + testWidgets('Scrollbar thumb can be dragged with long press - reverse', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: const CupertinoScrollbar( + child: SingleChildScrollView( + reverse: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const double scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); + // Scroll up by swiping down. + await scrollGesture.moveBy(const Offset(0.0, scrollAmount)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: _kScrollbarColor.color, + )); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + int hapticFeedbackCalls = 0; + SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls++; + } + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 550.0)); + await tester.pump(_kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(_kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb up to scroll up. + await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: _kScrollbarColor.color, + )); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(_kScrollbarTimeToFade); + await tester.pump(_kScrollbarFadeDuration); + }); + testWidgets('Scrollbar changes thickness and radius when dragged', (WidgetTester tester) async { const double thickness = 20; const double thicknessWhileDragging = 40; @@ -727,6 +801,80 @@ void main() { await tester.pump(_kScrollbarFadeDuration); }); + testWidgets('Scrollbar thumb can be dragged with long press - horizontal axis, reverse', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: CupertinoScrollbar( + controller: scrollController, + child: SingleChildScrollView( + reverse: true, + controller: scrollController, + scrollDirection: Axis.horizontal, + child: const SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ); + + expect(scrollController.offset, 0.0); + + // Scroll a bit. + const double scrollAmount = 10.0; + final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); + // Scroll right by swiping right. + await scrollGesture.moveBy(const Offset(scrollAmount, 0.0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // Scrollbar thumb is fully showing and scroll offset has moved by + // scrollAmount. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: _kScrollbarColor.color, + )); + expect(scrollController.offset, scrollAmount); + await scrollGesture.up(); + await tester.pump(); + + int hapticFeedbackCalls = 0; + SystemChannels.platform.setMockMethodCallHandler((MethodCall methodCall) async { + if (methodCall.method == 'HapticFeedback.vibrate') { + hapticFeedbackCalls++; + } + }); + + // Long press on the scrollbar thumb and expect a vibration after it resizes. + expect(hapticFeedbackCalls, 0); + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(750.0, 596.0)); + await tester.pump(_kLongPressDuration); + expect(hapticFeedbackCalls, 0); + await tester.pump(_kScrollbarResizeDuration); + // Allow the haptic feedback some slack. + await tester.pump(const Duration(milliseconds: 1)); + expect(hapticFeedbackCalls, 1); + + // Drag the thumb to scroll back to the right. + await dragScrollbarGesture.moveBy(const Offset(-scrollAmount, 0.0)); + await tester.pump(const Duration(milliseconds: 100)); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + // The scrollbar thumb is still fully visible. + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: _kScrollbarColor.color, + )); + + // Let the thumb fade out so all timers have resolved. + await tester.pump(_kScrollbarTimeToFade); + await tester.pump(_kScrollbarFadeDuration); + }); + testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async { final ScrollController scrollController = ScrollController(); await tester.pumpWidget( diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart index 78100b87d5..e84dd973ff 100644 --- a/packages/flutter/test/widgets/scrollbar_test.dart +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -1339,6 +1339,61 @@ void main() { ); }); + testWidgets('Scrollbar thumb can be dragged in reverse', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: RawScrollbar( + isAlwaysShown: true, + controller: scrollController, + child: const SingleChildScrollView( + reverse: true, + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0.0); + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) + ..rect( + rect: const Rect.fromLTRB(794.0, 510.0, 800.0, 600.0), + color: const Color(0x66BCBCBC), + ), + ); + + // Drag the thumb up to scroll up. + const double scrollAmount = 10.0; + final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 550.0)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.moveBy(const Offset(0.0, -scrollAmount)); + await tester.pumpAndSettle(); + await dragScrollbarGesture.up(); + await tester.pumpAndSettle(); + + // The view has scrolled more than it would have by a swipe gesture of the + // same distance. + expect(scrollController.offset, greaterThan(scrollAmount * 2)); + expect( + find.byType(RawScrollbar), + paints + ..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0)) + ..rect( + rect: const Rect.fromLTRB(794.0, 500.0, 800.0, 590.0), + color: const Color(0x66BCBCBC), + ), + ); + }); testWidgets('ScrollbarPainter asserts if scrollbarOrientation is used with wrong axisDirection', (WidgetTester tester) async { final ScrollbarPainter painter = ScrollbarPainter( color: _kScrollbarColor,