diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 1206806e97..14f7d27970 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -535,30 +535,60 @@ class SelectableRegionState extends State with TextSelectionDe } void _initMouseGestureRecognizer() { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.iOS: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - _gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer(debugOwner:this), - (TapAndPanGestureRecognizer instance) { - instance - ..onTapDown = _startNewMouseSelectionGesture - ..onTapUp = _handleMouseTapUp - ..onDragStart = _handleMouseDragStart - ..onDragUpdate = _handleMouseDragUpdate - ..onDragEnd = _handleMouseDragEnd - ..onCancel = _clearSelection - ..dragStartBehavior = DragStartBehavior.down; - }, - ); - } + _gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer( + debugOwner:this, + supportedDevices: { PointerDeviceKind.mouse }, + ), + (TapAndPanGestureRecognizer instance) { + instance + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = _clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); } void _initTouchGestureRecognizer() { + // A [TapAndHorizontalDragGestureRecognizer] is used on non-precise pointer devices + // like PointerDeviceKind.touch so [SelectableRegion] gestures do not conflict with + // ancestor Scrollable gestures in common scenarios like a vertically scrolling list view. + _gestureRecognizers[TapAndHorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapAndHorizontalDragGestureRecognizer( + debugOwner:this, + supportedDevices: PointerDeviceKind.values.where((PointerDeviceKind device) { + return device != PointerDeviceKind.mouse; + }).toSet(), + ), + (TapAndHorizontalDragGestureRecognizer instance) { + instance + // iOS does not provide a device specific touch slop + // unlike Android (~8.0), so the touch slop for a [Scrollable] + // always default to kTouchSlop which is 18.0. When + // [SelectableRegion] is the child of a horizontal + // scrollable that means the [SelectableRegion] will + // always win the gesture arena when competing with + // the ancestor scrollable because they both have + // the same touch slop threshold and the child receives + // the [PointerEvent] first. To avoid this conflict + // and ensure a smooth scrolling experience, on + // iOS the [TapAndHorizontalDragGestureRecognizer] + // will wait for all other gestures to lose before + // declaring victory. + ..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS + ..onTapDown = _startNewMouseSelectionGesture + ..onTapUp = _handleMouseTapUp + ..onDragStart = _handleMouseDragStart + ..onDragUpdate = _handleMouseDragUpdate + ..onDragEnd = _handleMouseDragEnd + ..onCancel = _clearSelection + ..dragStartBehavior = DragStartBehavior.down; + }, + ); _gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices), (LongPressGestureRecognizer instance) { diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index f4ff64e050..304b037dd1 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -312,6 +312,144 @@ void main() { semantics.dispose(); }); + testWidgets('Horizontal PageView beats SelectionArea child touch drag gestures on iOS', (WidgetTester tester) async { + final PageController pageController = PageController(); + const String testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + addTearDown(pageController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + controller: pageController, + children: [ + Center( + child: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: const Text(testValue), + ), + ), + const SizedBox( + height: 200.0, + child: Center( + child: Text('Page 2'), + ), + ), + ], + ), + ), + ); + + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text(testValue), matching: find.byType(RichText))); + final Offset gPos = textOffsetToPosition(paragraph, testValue.indexOf('g')); + final Offset pPos = textOffsetToPosition(paragraph, testValue.indexOf('p')); + + // A double tap + drag should take precendence over parent drags. + final TestGesture gesture = await tester.startGesture(gPos); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await gesture.down(gPos); + await tester.pumpAndSettle(); + await gesture.moveTo(pPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(paragraph.selections, isNotEmpty); + expect(paragraph.selections[0], TextSelection(baseOffset: testValue.indexOf('g'), extentOffset: testValue.indexOf('p') + 3)); + + expect(pageController.page, isNotNull); + expect(pageController.page, 0.0); + // A horizontal drag directly on the SelectableRegion should move the page + // view to the next page. + final Rect selectableTextRect = tester.getRect(find.byType(SelectableRegion)); + await tester.dragFrom(selectableTextRect.centerRight - const Offset(0.1, 0.0), const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + expect(pageController.page, isNotNull); + expect(pageController.page, 1.0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + skip: kIsWeb, // https://github.com/flutter/flutter/issues/125582. + ); + + testWidgets('Vertical PageView beats SelectionArea child touch drag gestures', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/150897. + final PageController pageController = PageController(); + const String testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + addTearDown(pageController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PageView( + scrollDirection: Axis.vertical, + controller: pageController, + children: [ + Center( + child: SelectableRegion( + focusNode: focusNode, + selectionControls: materialTextSelectionControls, + child: const Text(testValue), + ), + ), + const SizedBox( + height: 200.0, + child: Center( + child: Text('Page 2'), + ), + ), + ], + ), + ), + ); + + final RenderParagraph paragraph = tester.renderObject(find.descendant(of: find.text(testValue), matching: find.byType(RichText))); + final Offset gPos = textOffsetToPosition(paragraph, testValue.indexOf('g')); + final Offset pPos = textOffsetToPosition(paragraph, testValue.indexOf('p')); + + // A double tap + drag should take precendence over parent drags. + final TestGesture gesture = await tester.startGesture(gPos); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.up(); + await tester.pump(); + await gesture.down(gPos); + await tester.pumpAndSettle(); + await gesture.moveTo(pPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(paragraph.selections, isNotEmpty); + expect(paragraph.selections[0], TextSelection(baseOffset: testValue.indexOf('g'), extentOffset: testValue.indexOf('p') + 3)); + + expect(pageController.page, isNotNull); + expect(pageController.page, 0.0); + // A vertical drag directly on the SelectableRegion should move the page + // view to the next page. + final Rect selectableTextRect = tester.getRect(find.byType(SelectableRegion)); + // Simulate a pan by drag vertically first. + await gesture.down(selectableTextRect.center); + await tester.pump(); + await gesture.moveTo(selectableTextRect.center + const Offset(0.0, -200.0)); + // Introduce horizontal movement. + await gesture.moveTo(selectableTextRect.center + const Offset(5.0, -300.0)); + await gesture.moveTo(selectableTextRect.center + const Offset(-10.0, -400.0)); + // Continue dragging vertically. + await gesture.moveTo(selectableTextRect.center + const Offset(0.0, -500.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(pageController.page, isNotNull); + expect(pageController.page, 1.0); + }, + variant: TargetPlatformVariant.mobile(), + skip: kIsWeb, // https://github.com/flutter/flutter/issues/125582. + ); + testWidgets('mouse single-click selection collapses the selection', (WidgetTester tester) async { final UniqueKey spy = UniqueKey(); final FocusNode focusNode = FocusNode();