diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index 1c66caf411..5b4b738469 100644 --- a/packages/flutter/lib/src/cupertino/scrollbar.dart +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -241,7 +241,7 @@ class _CupertinoScrollbarState extends State with TickerProv late Animation _fadeoutOpacityAnimation; late AnimationController _thicknessAnimationController; Timer? _fadeoutTimer; - double? _dragScrollbarPositionY; + double? _dragScrollbarAxisPosition; Drag? _drag; double get _thickness { @@ -342,18 +342,25 @@ class _CupertinoScrollbarState extends State with TickerProv // position, and create/update the drag event with that position. final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta); final double scrollOffsetGlobal = scrollOffsetLocal + _currentController!.position.pixels; + final Axis direction = _currentController!.position.axis; if (_drag == null) { _drag = _currentController!.position.drag( DragStartDetails( - globalPosition: Offset(0.0, scrollOffsetGlobal), + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), ), () {}, ); } else { _drag!.update(DragUpdateDetails( - globalPosition: Offset(0.0, scrollOffsetGlobal), - delta: Offset(0.0, -scrollOffsetLocal), + globalPosition: direction == Axis.vertical + ? Offset(0.0, scrollOffsetGlobal) + : Offset(scrollOffsetGlobal, 0.0), + delta: direction == Axis.vertical + ? Offset(0.0, -scrollOffsetLocal) + : Offset(-scrollOffsetLocal, 0.0), primaryDelta: -scrollOffsetLocal, )); } @@ -369,33 +376,43 @@ class _CupertinoScrollbarState extends State with TickerProv } } - bool _checkVertical() { + Axis? _getDirection() { try { - return _currentController!.position.axis == Axis.vertical; + return _currentController!.position.axis; } catch (_) { // Ignore the gesture if we cannot determine the direction. - return false; + return null; } } - double _pressStartY = 0.0; + double _pressStartAxisPosition = 0.0; // Long press event callbacks handle the gesture where the user long presses // on the scrollbar thumb and then drags the scrollbar without releasing. void _handleLongPressStart(LongPressStartDetails details) { _currentController = _controller; - if (!_checkVertical()) { + final Axis? direction = _getDirection(); + if (direction == null) { return; } - _pressStartY = details.localPosition.dy; _fadeoutTimer?.cancel(); _fadeoutAnimationController.forward(); - _dragScrollbar(details.localPosition.dy); - _dragScrollbarPositionY = details.localPosition.dy; + switch (direction) { + case Axis.vertical: + _pressStartAxisPosition = details.localPosition.dy; + _dragScrollbar(details.localPosition.dy); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _pressStartAxisPosition = details.localPosition.dx; + _dragScrollbar(details.localPosition.dx); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } } void _handleLongPress() { - if (!_checkVertical()) { + if (_getDirection() == null) { return; } _fadeoutTimer?.cancel(); @@ -405,37 +422,57 @@ class _CupertinoScrollbarState extends State with TickerProv } void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { - if (!_checkVertical()) { + final Axis? direction = _getDirection(); + if (direction == null) { return; } - _dragScrollbar(details.localPosition.dy - _dragScrollbarPositionY!); - _dragScrollbarPositionY = details.localPosition.dy; + switch(direction) { + case Axis.vertical: + _dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dy; + break; + case Axis.horizontal: + _dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!); + _dragScrollbarAxisPosition = details.localPosition.dx; + break; + } } void _handleLongPressEnd(LongPressEndDetails details) { - if (!_checkVertical()) { + final Axis? direction = _getDirection(); + if (direction == null) { return; } - _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy); - if (details.velocity.pixelsPerSecond.dy.abs() < 10 && - (details.localPosition.dy - _pressStartY).abs() > 0) { - HapticFeedback.mediumImpact(); + switch(direction) { + case Axis.vertical: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction); + if (details.velocity.pixelsPerSecond.dy.abs() < 10 && + (details.localPosition.dy - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; + case Axis.horizontal: + _handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction); + if (details.velocity.pixelsPerSecond.dx.abs() < 10 && + (details.localPosition.dx - _pressStartAxisPosition).abs() > 0) { + HapticFeedback.mediumImpact(); + } + break; } _currentController = null; } - void _handleDragScrollEnd(double trackVelocityY) { + void _handleDragScrollEnd(double trackVelocity, Axis direction) { _startFadeoutTimer(); _thicknessAnimationController.reverse(); - _dragScrollbarPositionY = null; - final double scrollVelocityY = _painter!.getTrackToScroll(trackVelocityY); + _dragScrollbarAxisPosition = null; + final double scrollVelocity = _painter!.getTrackToScroll(trackVelocity); _drag?.end(DragEndDetails( - primaryVelocity: -scrollVelocityY, + primaryVelocity: -scrollVelocity, velocity: Velocity( - pixelsPerSecond: Offset( - 0.0, - -scrollVelocityY, - ), + pixelsPerSecond: direction == Axis.vertical + ? Offset(0.0, -scrollVelocity) + : Offset(-scrollVelocity, 0.0), ), )); _drag = null; @@ -458,7 +495,7 @@ class _CupertinoScrollbarState extends State with TickerProv _painter!.update(notification.metrics, notification.metrics.axisDirection); } else if (notification is ScrollEndNotification) { // On iOS, the scrollbar can only go away once the user lifted the finger. - if (_dragScrollbarPositionY == null) { + if (_dragScrollbarAxisPosition == null) { _startFadeoutTimer(); } } diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index 6fe3e4041a..e7483146c0 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -607,4 +607,77 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); }); + + testWidgets('Scrollbar thumb can be dragged with long press - horizontal axis', (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( + 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 left. + 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(50.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 down to scroll back to the left. + 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); + }); }