diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index 95850c6c0872..8d9d258d91ce 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -143,9 +143,10 @@ class CupertinoTextSelectionControls extends TextSelectionControls { ..translate(-desiredSize.width / 2, -desiredSize.height / 2), child: handle, ); - // iOS doesn't draw anything for collapsed selections. + // iOS should draw an invisible box so the handle can still receive gestures + // on collapsed selections. case TextSelectionHandleType.collapsed: - return const SizedBox.shrink(); + return SizedBox.fromSize(size: getHandleSize(textLineHeight)); } } diff --git a/packages/flutter/lib/src/gestures/tap_and_drag.dart b/packages/flutter/lib/src/gestures/tap_and_drag.dart index 108beaf024ee..904166c9db9c 100644 --- a/packages/flutter/lib/src/gestures/tap_and_drag.dart +++ b/packages/flutter/lib/src/gestures/tap_and_drag.dart @@ -652,9 +652,12 @@ mixin _TapStatusTrackerMixin on OneSequenceGestureRecognizer { /// ### When competing with `TapGestureRecognizer` and `DragGestureRecognizer` /// /// Similar to [TapGestureRecognizer] and [DragGestureRecognizer], -/// [BaseTapAndDragGestureRecognizer] will not aggressively declare victory when it detects -/// a tap, so when it is competing with those gesture recognizers and others it has a chance -/// of losing. +/// [BaseTapAndDragGestureRecognizer] will not aggressively declare victory when +/// it detects a tap, so when it is competing with those gesture recognizers and +/// others it has a chance of losing. Similarly, when `eagerVictoryOnDrag` is set +/// to `false`, this recognizer will not aggressively declare victory when it +/// detects a drag. By default, `eagerVictoryOnDrag` is set to `true`, so this +/// recognizer will aggressively declare victory when it detects a drag. /// /// When competing against [TapGestureRecognizer], if the pointer does not move past the tap /// tolerance, then the recognizer that entered the arena first will win. In this case the @@ -748,6 +751,7 @@ sealed class BaseTapAndDragGestureRecognizer extends OneSequenceGestureRecognize super.debugOwner, super.supportedDevices, super.allowedButtonsFilter, + this.eagerVictoryOnDrag = true, }) : _deadline = kPressTimeout, dragStartBehavior = DragStartBehavior.start; @@ -782,6 +786,15 @@ sealed class BaseTapAndDragGestureRecognizer extends OneSequenceGestureRecognize @override int? maxConsecutiveTap; + /// Whether this recognizer eagerly declares victory when it has detected + /// a drag. + /// + /// When this value is `false`, this recognizer will wait until it is the last + /// recognizer in the gesture arena before declaring victory on a drag. + /// + /// Defaults to `true`. + bool eagerVictoryOnDrag; + /// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapDown} /// /// This triggers after the down event, once a short timeout ([kPressTimeout]) has @@ -984,14 +997,24 @@ sealed class BaseTapAndDragGestureRecognizer extends OneSequenceGestureRecognize _wonArenaForPrimaryPointer = true; - // resolve(GestureDisposition.accepted) will be called when the [PointerMoveEvent] has - // moved a sufficient global distance. - if (_start != null) { + // resolve(GestureDisposition.accepted) will be called when the [PointerMoveEvent] + // has moved a sufficient global distance to be considered a drag and + // `eagerVictoryOnDrag` is set to `true`. + if (_start != null && eagerVictoryOnDrag) { assert(_dragState == _DragState.accepted); assert(currentUp == null); _acceptDrag(_start!); } + // This recognizer will wait until it is the last one in the gesture arena + // before accepting a drag when `eagerVictoryOnDrag` is set to `false`. + if (_start != null && !eagerVictoryOnDrag) { + assert(_dragState == _DragState.possible); + assert(currentUp == null); + _dragState = _DragState.accepted; + _acceptDrag(_start!); + } + if (currentUp != null) { _checkTapUp(currentUp!); } @@ -1076,7 +1099,8 @@ sealed class BaseTapAndDragGestureRecognizer extends OneSequenceGestureRecognize // This can occur when the recognizer is accepted before a [PointerMoveEvent] has been // received that moves the pointer a sufficient global distance to be considered a drag. - if (_start != null) { + if (_start != null && _wonArenaForPrimaryPointer) { + _dragState = _DragState.accepted; _acceptDrag(_start!); } } @@ -1156,9 +1180,11 @@ sealed class BaseTapAndDragGestureRecognizer extends OneSequenceGestureRecognize if (_hasSufficientGlobalDistanceToAccept(event.kind) || (_wonArenaForPrimaryPointer && _globalDistanceMovedAllAxes.abs() > computePanSlop(event.kind, gestureSettings))) { _start = event; - _dragState = _DragState.accepted; - if (!_wonArenaForPrimaryPointer) { - resolve(GestureDisposition.accepted); + if (eagerVictoryOnDrag) { + _dragState = _DragState.accepted; + if (!_wonArenaForPrimaryPointer) { + resolve(GestureDisposition.accepted); + } } } } diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 345f676db5f6..910b915c10e7 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -1898,6 +1898,11 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S math.max((interactiveRect.height - handleRect.height) / 2, 0), ); + // Make sure a drag is eagerly accepted. This is used on iOS to match the + // behavior where a drag directly on a collapse handle will always win against + // other drag gestures. + final bool eagerlyAcceptDragWhenCollapsed = widget.type == TextSelectionHandleType.collapsed && defaultTargetPlatform == TargetPlatform.iOS; + return CompositedTransformFollower( link: widget.handleLayerLink, offset: interactiveRect.topLeft, @@ -1924,6 +1929,7 @@ class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with S (PanGestureRecognizer instance) { instance ..dragStartBehavior = widget.dragStartBehavior + ..gestureSettings = eagerlyAcceptDragWhenCollapsed ? const DeviceGestureSettings(touchSlop: 1.0) : null ..onStart = widget.onSelectionHandleDragStart ..onUpdate = widget.onSelectionHandleDragUpdate ..onEnd = widget.onSelectionHandleDragEnd; @@ -2081,18 +2087,6 @@ class TextSelectionGestureDetectorBuilder { && selection.end >= textPosition.offset; } - /// Returns true if position was on selection. - bool _positionOnSelection(Offset position, TextSelection? targetSelection) { - if (targetSelection == null) { - return false; - } - - final TextPosition textPosition = renderEditable.getPositionForPoint(position); - - return targetSelection.start <= textPosition.offset - && targetSelection.end >= textPosition.offset; - } - // Expand the selection to the given global position. // // Either base or extent will be moved to the last tapped position, whichever @@ -2203,15 +2197,6 @@ class TextSelectionGestureDetectorBuilder { // inversion of the base and offset happens. TextSelection? _dragStartSelection; - // For tap + drag gesture on iOS, whether the position where the drag started - // was on the previous TextSelection. iOS uses this value to determine if - // the cursor should move on drag update. - // - // If the drag started on the previous selection then the cursor will move on - // drag update. If the drag did not start on the previous selection then the - // cursor will not move on drag update. - bool? _dragBeganOnPreviousSelection; - // For iOS long press behavior when the field is not focused. iOS uses this value // to determine if a long press began on a field that was not focused. // @@ -2807,7 +2792,6 @@ class TextSelectionGestureDetectorBuilder { _dragStartSelection = renderEditable.selection; _dragStartScrollOffset = _scrollPosition; _dragStartViewportOffset = renderEditable.offset.pixels; - _dragBeganOnPreviousSelection = _positionOnSelection(details.globalPosition, _dragStartSelection); if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(details.consecutiveTapCount) > 1) { // Do not set the selection on a consecutive tap and drag. @@ -2839,16 +2823,6 @@ class TextSelectionGestureDetectorBuilder { case PointerDeviceKind.invertedStylus: case PointerDeviceKind.touch: case PointerDeviceKind.unknown: - // For iOS platforms, a touch drag does not initiate unless the - // editable has focus and the drag began on the previous selection. - assert(_dragBeganOnPreviousSelection != null); - if (renderEditable.hasFocus && _dragBeganOnPreviousSelection!) { - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.drag, - ); - _showMagnifierIfSupportedByPlatform(details.globalPosition); - } case null: } case TargetPlatform.android: @@ -2975,12 +2949,10 @@ class TextSelectionGestureDetectorBuilder { switch (defaultTargetPlatform) { case TargetPlatform.iOS: - // With a touch device, nothing should happen, unless there was a double tap, or - // there was a collapsed selection, and the tap/drag position is at the collapsed selection. - // In that case the caret should move with the drag position. - // // With a mouse device, a drag should select the range from the origin of the drag // to the current position of the drag. + // + // With a touch device, nothing should happen. switch (details.kind) { case PointerDeviceKind.mouse: case PointerDeviceKind.trackpad: @@ -2993,17 +2965,6 @@ class TextSelectionGestureDetectorBuilder { case PointerDeviceKind.invertedStylus: case PointerDeviceKind.touch: case PointerDeviceKind.unknown: - assert(_dragBeganOnPreviousSelection != null); - if (renderEditable.hasFocus - && _dragStartSelection!.isCollapsed - && _dragBeganOnPreviousSelection! - ) { - renderEditable.selectPositionAt( - from: details.globalPosition, - cause: SelectionChangedCause.drag, - ); - return _showMagnifierIfSupportedByPlatform(details.globalPosition); - } case null: break; } @@ -3100,8 +3061,6 @@ class TextSelectionGestureDetectorBuilder { /// callback. @protected void onDragSelectionEnd(TapDragEndDetails details) { - _dragBeganOnPreviousSelection = null; - if (_shouldShowSelectionToolbar && _TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) { editableText.showToolbar(); } @@ -3437,6 +3396,7 @@ class _TextSelectionGestureDetectorState extends State events; late BaseTapAndDragGestureRecognizer tapAndDrag; - void setUpTapAndPanGestureRecognizer() { + void setUpTapAndPanGestureRecognizer({ + bool eagerVictoryOnDrag = true, // This is the default for [BaseTapAndDragGestureRecognizer]. + }) { tapAndDrag = TapAndPanGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down + ..eagerVictoryOnDrag = eagerVictoryOnDrag ..maxConsecutiveTap = 3 ..onTapDown = (TapDragDownDetails details) { events.add('down#${details.consecutiveTapCount}'); @@ -653,6 +656,41 @@ void main() { 'panend#1']); }); + testGesture('Recognizer loses when competing against a DragGestureRecognizer for a drag when eagerVictoryOnDrag is disabled', (GestureTester tester) { + setUpTapAndPanGestureRecognizer(eagerVictoryOnDrag: false); + final PanGestureRecognizer pans = PanGestureRecognizer() + ..onStart = (DragStartDetails details) { + events.add('panstart'); + } + ..onUpdate = (DragUpdateDetails details) { + events.add('panupdate'); + } + ..onEnd = (DragEndDetails details) { + events.add('panend'); + } + ..onCancel = () { + events.add('pancancel'); + }; + + final TestPointer pointer = TestPointer(5); + final PointerDownEvent downB = pointer.down(const Offset(10.0, 10.0)); + // When competing against another [DragGestureRecognizer], the [TapAndPanGestureRecognizer] + // will only win when it is the last recognizer in the arena. + tapAndDrag.addPointer(downB); + pans.addPointer(downB); + tester.closeArena(5); + tester.route(downB); + tester.route(pointer.move(const Offset(40.0, 45.0))); + tester.route(pointer.up()); + expect( + events, + [ + 'panstart', + 'panend', + ], + ); + }); + testGesture('Beats LongPressGestureRecognizer on a consecutive tap greater than one', (GestureTester tester) { setUpTapAndPanGestureRecognizer(); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 19c187ece188..cd4b988a2753 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -2506,6 +2506,191 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS }), ); + testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS) - PageView', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/142624. + final TextEditingController controller = _textEditingController(); + final PageController pageController = PageController(); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PageView( + controller: pageController, + children: [ + Center( + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + const SizedBox( + height: 200.0, + child: Center( + child: Text('Page 2'), + ), + ), + ], + ), + ), + ), + ); + + const String testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(pageController.page, isNotNull); + expect(pageController.page, 0.0); + // A horizontal drag directly on the TextField, but not on the current + // collapsed selection should move the page view to the next page. + final Rect textFieldRect = tester.getRect(find.byType(TextField)); + await tester.dragFrom(textFieldRect.centerRight - const Offset(0.1, 0.0), const Offset(-500.0, 0.0)); + await tester.pumpAndSettle(); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + expect(pageController.page, isNotNull); + expect(pageController.page, 1.0); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + ); + + testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS) - TextField in Dismissible', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/124421. + final TextEditingController controller = _textEditingController(); + bool dismissed = false; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: ListView( + children: [ + Dismissible( + key: UniqueKey(), + onDismissed: (DismissDirection? direction) { + dismissed = true; + }, + child: TextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + ), + ), + ], + ), + ), + ), + ); + + const String testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + await tester.enterText(find.byType(TextField), testValue); + await skipPastScrollingAnimation(tester); + + final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a')); + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i')); + + // Tap on text field to gain focus, and set selection to '|a'. On iOS + // the selection is set to the word edge closest to the tap position. + // We await for kDoubleTapTimeout after the up event, so our next down event + // does not register as a double tap. + final TestGesture gesture = await tester.startGesture(aPos); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 0); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|a', where our selection was previously, and attempt move + // to '|g'. + await gesture.down(aPos); + await tester.pump(); + await gesture.moveTo(gPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('g')); + + // Release the pointer. + await gesture.up(); + await tester.pumpAndSettle(); + + // If the position we tap during a drag start is on the collapsed selection, then + // we can move the cursor with a drag. + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); + await tester.pump(); + await gesture.moveTo(iPos); + await tester.pumpAndSettle(); + + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, testValue.indexOf('i')); + + // End gesture and skip the magnifier hide animation, so it can release + // resources. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(dismissed, false); + // A horizontal drag directly on the TextField, but not on the current + // collapsed selection should allow for the Dismissible to be dismissed. + await tester.dragFrom(tester.getRect(find.byType(TextField)).centerRight - const Offset(0.1, 0.0), const Offset(-400.0, 0.0)); + await tester.pumpAndSettle(); + expect(dismissed, true); + }, + variant: const TargetPlatformVariant({ TargetPlatform.iOS }), + ); + testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS) - ListView', (WidgetTester tester) async { // This is a regression test for // https://github.com/flutter/flutter/issues/122519 @@ -2550,17 +2735,14 @@ void main() { // If the position we tap during a drag start is on the collapsed selection, then // we can move the cursor with a drag. // Here we tap on '|a', where our selection was previously, and attempt move - // to '|g'. The cursor will not move because the `VerticalDragGestureRecognizer` - // in the scrollable will beat the `TapAndHorizontalDragGestureRecognizer` - // in the TextField. This is because moving from `|a` to `|g` is a completely - // vertical movement. + // to '|g'. await gesture.down(aPos); await tester.pump(); await gesture.moveTo(gPos); await tester.pumpAndSettle(); expect(controller.selection.isCollapsed, true); - expect(controller.selection.baseOffset, 0); + expect(controller.selection.baseOffset, testValue.indexOf('g')); // Release the pointer. await gesture.up(); @@ -2568,11 +2750,8 @@ void main() { // If the position we tap during a drag start is on the collapsed selection, then // we can move the cursor with a drag. - // Here we tap on '|a', where our selection was previously, and move to '|i'. - // Unlike our previous attempt to drag to `|g`, this works because moving - // to `|i` includes a horizontal movement so the `TapAndHorizontalDragGestureRecognizer` - // in TextField can beat the `VerticalDragGestureRecognizer` in the scrollable. - await gesture.down(aPos); + // Here we tap on '|g', where our selection was previously, and move to '|i'. + await gesture.down(gPos); await tester.pump(); await gesture.moveTo(iPos); await tester.pumpAndSettle(); diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart index 682a07a2617d..8d65649fd3ea 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -3395,6 +3395,67 @@ void main() { }, ); + testWidgets('PageView beats SelectableText drag gestures (iOS)', (WidgetTester tester) async { + // This is a regression test for + // https://github.com/flutter/flutter/issues/130198. + final PageController pageController = PageController(); + const String testValue = 'abc def ghi jkl mno pqr stu vwx yz'; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: PageView( + controller: pageController, + children: const [ + Center( + child: SelectableText(testValue), + ), + SizedBox( + height: 200.0, + child: Center( + child: Text('Page 2'), + ), + ), + ], + ), + ), + ), + ); + + await skipPastScrollingAnimation(tester); + + final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g')); + final Offset pPos = textOffsetToPosition(tester, testValue.indexOf('p')); + + // A double tap + drag should take precendence over parent drags. + final TestGesture gesture = await tester.startGesture(gPos); + 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(); + final TextEditingValue currentValue = + tester.state(find.byType(EditableText)).textEditingValue; + expect(currentValue.selection, 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 SelectableText should move the page + // view to the next page. + final Rect selectableTextRect = tester.getRect(find.byType(SelectableText)); + 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: const TargetPlatformVariant({ TargetPlatform.iOS }), + ); + testWidgets( 'long press tap cannot initiate a double tap', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index fee2a0c5767f..805bb237f817 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -1175,8 +1175,8 @@ void main() { final Rect hitRect = tester.getRect(gestureDetector); final Rect textFieldRect = tester.getRect(find.byType(TextField)); - expect(hitRect.size.width, lessThan(textFieldRect.size.width)); - expect(hitRect.size.height, lessThan(textFieldRect.size.height)); + expect(hitRect.size.width, lessThanOrEqualTo(textFieldRect.size.width)); + expect(hitRect.size.height, lessThanOrEqualTo(textFieldRect.size.height)); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); group('SelectionOverlay', () {