diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 15eaf304ae4..24b45681e6c 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -69,6 +69,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { PointerDeviceKind? kind, this.dragStartBehavior = DragStartBehavior.start, this.velocityTrackerBuilder = _defaultBuilder, + this.supportedDevices = _kAllPointerDeviceKinds }) : assert(dragStartBehavior != null), super(debugOwner: debugOwner, kind: kind); @@ -200,6 +201,11 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { /// match the native behavior on that platform. GestureVelocityTrackerBuilder velocityTrackerBuilder; + /// The device types that this gesture recognizer will accept drags from. + /// + /// If not specified, defaults to all pointer kinds. + Set supportedDevices; + _DragState _state = _DragState.ready; late OffsetPair _initialPosition; late OffsetPair _pendingDragOffset; @@ -230,6 +236,9 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer { @override bool isPointerAllowed(PointerEvent event) { + if (!supportedDevices.contains(event.kind)) { + return false; + } if (_initialButtons == null) { switch (event.buttons) { case kPrimaryButton: @@ -508,7 +517,8 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { VerticalDragGestureRecognizer({ Object? debugOwner, PointerDeviceKind? kind, - }) : super(debugOwner: debugOwner, kind: kind); + Set supportedDevices = _kAllPointerDeviceKinds, + }) : super(debugOwner: debugOwner, kind: kind, supportedDevices: supportedDevices); @override bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) { @@ -532,6 +542,10 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer { String get debugDescription => 'vertical drag'; } +const Set _kAllPointerDeviceKinds = { + ...PointerDeviceKind.values, +}; + /// Recognizes movement in the horizontal direction. /// /// Used for horizontal scrolling. @@ -549,7 +563,8 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer { HorizontalDragGestureRecognizer({ Object? debugOwner, PointerDeviceKind? kind, - }) : super(debugOwner: debugOwner, kind: kind); + Set supportedDevices = _kAllPointerDeviceKinds, + }) : super(debugOwner: debugOwner, kind: kind, supportedDevices: supportedDevices); @override bool isFlingGesture(VelocityEstimate estimate, PointerDeviceKind kind) { diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 38a76320817..2cbb5282298 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -14,6 +14,13 @@ import 'scrollbar.dart'; const Color _kDefaultGlowColor = Color(0xFFFFFFFF); +/// Device types that scrollables should accept drag gestures from by default. +const Set _kTouchLikeDeviceTypes = { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, +}; + /// Describes how [Scrollable] widgets should behave. /// /// {@template flutter.widgets.scrollBehavior} @@ -52,6 +59,7 @@ class ScrollBehavior { ScrollBehavior copyWith({ bool scrollbars = true, bool overscroll = true, + Set? dragDevices, ScrollPhysics? physics, TargetPlatform? platform, }) { @@ -61,6 +69,7 @@ class ScrollBehavior { overscrollIndicator: overscroll, physics: physics, platform: platform, + dragDevices: dragDevices, ); } @@ -69,6 +78,14 @@ class ScrollBehavior { /// Defaults to the current platform. TargetPlatform getPlatform(BuildContext context) => defaultTargetPlatform; + /// The device kinds that the scrollable will accept drag gestures from. + /// + /// By default only [PointerDeviceKind.touch], [PointerDeviceKind.stylus], and + /// [PointerDeviceKind.invertedStylus] are configured to create drag gestures. + /// Enabling this for [PointerDeviceKind.mouse] will make it difficult or + /// impossible to select text in scrollable containers and is not recommended. + Set get dragDevices => _kTouchLikeDeviceTypes; + /// Wraps the given widget, which scrolls in the given [AxisDirection]. /// /// For example, on Android, this method wraps the given widget with a @@ -200,13 +217,18 @@ class _WrappedScrollBehavior implements ScrollBehavior { this.overscrollIndicator = true, this.physics, this.platform, - }); + Set? dragDevices, + }) : _dragDevices = dragDevices; final ScrollBehavior delegate; final bool scrollbar; final bool overscrollIndicator; final ScrollPhysics? physics; final TargetPlatform? platform; + final Set? _dragDevices; + + @override + Set get dragDevices => _dragDevices ?? delegate.dragDevices; @override Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) { @@ -233,12 +255,14 @@ class _WrappedScrollBehavior implements ScrollBehavior { bool overscroll = true, ScrollPhysics? physics, TargetPlatform? platform, + Set? dragDevices, }) { return delegate.copyWith( scrollbars: scrollbars, overscroll: overscroll, physics: physics, platform: platform, + dragDevices: dragDevices, ); } @@ -259,6 +283,7 @@ class _WrappedScrollBehavior implements ScrollBehavior { || oldDelegate.overscrollIndicator != overscrollIndicator || oldDelegate.physics != physics || oldDelegate.platform != platform + || setEquals(oldDelegate.dragDevices, dragDevices) || delegate.shouldNotify(oldDelegate.delegate); } diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index a339e3cd774..c9398310ced 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -565,7 +565,8 @@ class ScrollableState extends State with TickerProviderStateMixin, R ..minFlingVelocity = _physics?.minFlingVelocity ..maxFlingVelocity = _physics?.maxFlingVelocity ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) - ..dragStartBehavior = widget.dragStartBehavior; + ..dragStartBehavior = widget.dragStartBehavior + ..supportedDevices = _configuration.dragDevices; }, ), }; @@ -585,7 +586,8 @@ class ScrollableState extends State with TickerProviderStateMixin, R ..minFlingVelocity = _physics?.minFlingVelocity ..maxFlingVelocity = _physics?.maxFlingVelocity ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) - ..dragStartBehavior = widget.dragStartBehavior; + ..dragStartBehavior = widget.dragStartBehavior + ..supportedDevices = _configuration.dragDevices; }, ), }; diff --git a/packages/flutter/test/gestures/drag_test.dart b/packages/flutter/test/gestures/drag_test.dart index d8e9f07392a..67afcfa5f6c 100644 --- a/packages/flutter/test/gestures/drag_test.dart +++ b/packages/flutter/test/gestures/drag_test.dart @@ -181,6 +181,106 @@ void main() { didEndDrag = false; }); + testGesture('Should reject mouse drag when configured to ignore mouse pointers - Horizontal', (GestureTester tester) { + final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer(supportedDevices: { + PointerDeviceKind.touch, + }) ..dragStartBehavior = DragStartBehavior.down; + addTearDown(drag.dispose); + + bool didStartDrag = false; + drag.onStart = (_) { + didStartDrag = true; + }; + + double? updatedDelta; + drag.onUpdate = (DragUpdateDetails details) { + updatedDelta = details.primaryDelta; + }; + + bool didEndDrag = false; + drag.onEnd = (DragEndDetails details) { + didEndDrag = true; + }; + + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + drag.addPointer(down); + tester.closeArena(5); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(down); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.move(const Offset(20.0, 25.0))); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.move(const Offset(20.0, 25.0))); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.up()); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + }); + + testGesture('Should reject mouse drag when configured to ignore mouse pointers - Vertical', (GestureTester tester) { + final VerticalDragGestureRecognizer drag = VerticalDragGestureRecognizer(supportedDevices: { + PointerDeviceKind.touch, + })..dragStartBehavior = DragStartBehavior.down; + addTearDown(drag.dispose); + + bool didStartDrag = false; + drag.onStart = (_) { + didStartDrag = true; + }; + + double? updatedDelta; + drag.onUpdate = (DragUpdateDetails details) { + updatedDelta = details.primaryDelta; + }; + + bool didEndDrag = false; + drag.onEnd = (DragEndDetails details) { + didEndDrag = true; + }; + + final TestPointer pointer = TestPointer(5, PointerDeviceKind.mouse); + final PointerDownEvent down = pointer.down(const Offset(10.0, 10.0)); + drag.addPointer(down); + tester.closeArena(5); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(down); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.move(const Offset(25.0, 20.0))); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.move(const Offset(25.0, 20.0))); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + + tester.route(pointer.up()); + expect(didStartDrag, isFalse); + expect(updatedDelta, isNull); + expect(didEndDrag, isFalse); + }); + testGesture('Should report original timestamps', (GestureTester tester) { final HorizontalDragGestureRecognizer drag = HorizontalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down; addTearDown(drag.dispose); diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index ec5a3488de5..9df01fce9aa 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -17,9 +17,13 @@ Future pumpTest( bool scrollable = true, bool reverse = false, ScrollController? controller, + bool enableMouseDrag = true, }) async { await tester.pumpWidget(MaterialApp( - scrollBehavior: const NoScrollbarBehavior(), + scrollBehavior: const NoScrollbarBehavior().copyWith(dragDevices: enableMouseDrag + ? {...ui.PointerDeviceKind.values} + : null, + ), theme: ThemeData( platform: platform, ), @@ -1269,6 +1273,46 @@ void main() { expect(tester.takeException(), null); }); + + testWidgets('Does not scroll with mouse pointer drag when behavior is configured to ignore them', (WidgetTester tester) async { + await pumpTest(tester, debugDefaultTargetPlatformOverride, enableMouseDrag: false); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); + + await gesture.moveBy(const Offset(0.0, -200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 0.0); + + await gesture.moveBy(const Offset(0.0, 200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 0.0); + + await gesture.removePointer(); + await tester.pump(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.android })); + + testWidgets('Does scroll with mouse pointer drag when behavior is not configured to ignore them', (WidgetTester tester) async { + await pumpTest(tester, debugDefaultTargetPlatformOverride, enableMouseDrag: true); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.mouse); + + await gesture.moveBy(const Offset(0.0, -200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 200.0); + + await gesture.moveBy(const Offset(0.0, 200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 0.0); + + await gesture.removePointer(); + await tester.pump(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS, TargetPlatform.android })); } // ignore: must_be_immutable diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 8da29687e2e..6ced9529688 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -530,6 +530,7 @@ abstract class WidgetController { double touchSlopX = kDragSlopDefault, double touchSlopY = kDragSlopDefault, bool warnIfMissed = true, + PointerDeviceKind kind = PointerDeviceKind.touch, }) { return dragFrom( getCenter(finder, warnIfMissed: warnIfMissed, callee: 'drag'), @@ -538,6 +539,7 @@ abstract class WidgetController { buttons: buttons, touchSlopX: touchSlopX, touchSlopY: touchSlopY, + kind: kind, ); } @@ -559,10 +561,11 @@ abstract class WidgetController { int buttons = kPrimaryButton, double touchSlopX = kDragSlopDefault, double touchSlopY = kDragSlopDefault, + PointerDeviceKind kind = PointerDeviceKind.touch, }) { assert(kDragSlopDefault > kTouchSlop); return TestAsyncUtils.guard(() async { - final TestGesture gesture = await startGesture(startLocation, pointer: pointer, buttons: buttons); + final TestGesture gesture = await startGesture(startLocation, pointer: pointer, buttons: buttons, kind: kind); assert(gesture != null); final double xSign = offset.dx.sign;