diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 7b4768ad3ee..76a8d6c881b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -30,6 +30,13 @@ const EdgeInsets _kFloatingCursorSizeIncrease = EdgeInsets.symmetric(horizontal: // The corner radius of the floating cursor in pixels. const Radius _kFloatingCursorRadius = Radius.circular(1.0); +// This constant represents the shortest squared distance required between the floating cursor +// and the regular cursor when both are present in the text field. +// If the squared distance between the two cursors is less than this value, +// it's not necessary to display both cursors at the same time. +// This behavior is consistent with the one observed in iOS UITextField. +const double _kShortestDistanceSquaredWithFloatingAndRegularCursors = 15.0 * 15.0; + /// Represents the coordinates of the point in a selection, and the text /// direction at that point, relative to top left of the [RenderEditable] that /// holds the selection. @@ -2360,19 +2367,35 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, // difference in the rendering position and the raw offset value. Offset _relativeOrigin = Offset.zero; Offset? _previousOffset; + bool _shouldResetOrigin = true; bool _resetOriginOnLeft = false; bool _resetOriginOnRight = false; bool _resetOriginOnTop = false; bool _resetOriginOnBottom = false; double? _resetFloatingCursorAnimationValue; + static Offset _calculateAdjustedCursorOffset(Offset offset, Rect boundingRects) { + final double adjustedX = clampDouble(offset.dx, boundingRects.left, boundingRects.right); + final double adjustedY = clampDouble(offset.dy, boundingRects.top, boundingRects.bottom); + return Offset(adjustedX, adjustedY); + } + /// Returns the position within the text field closest to the raw cursor offset. - Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset) { + Offset calculateBoundedFloatingCursorOffset(Offset rawCursorOffset, {bool? shouldResetOrigin}) { Offset deltaPosition = Offset.zero; final double topBound = -floatingCursorAddedMargin.top; - final double bottomBound = _textPainter.height - preferredLineHeight + floatingCursorAddedMargin.bottom; + final double bottomBound = math.min(size.height, _textPainter.height) - preferredLineHeight + floatingCursorAddedMargin.bottom; final double leftBound = -floatingCursorAddedMargin.left; - final double rightBound = _textPainter.width + floatingCursorAddedMargin.right; + final double rightBound = math.min(size.width, _textPainter.width) + floatingCursorAddedMargin.right; + final Rect boundingRects = Rect.fromLTRB(leftBound, topBound, rightBound, bottomBound); + + if (shouldResetOrigin != null) { + _shouldResetOrigin = shouldResetOrigin; + } + + if (!_shouldResetOrigin) { + return _calculateAdjustedCursorOffset(rawCursorOffset, boundingRects); + } if (_previousOffset != null) { deltaPosition = rawCursorOffset - _previousOffset!; @@ -2381,34 +2404,32 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, // If the raw cursor offset has gone off an edge, we want to reset the relative // origin of the dragging when the user drags back into the field. if (_resetOriginOnLeft && deltaPosition.dx > 0) { - _relativeOrigin = Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); + _relativeOrigin = Offset(rawCursorOffset.dx - boundingRects.left, _relativeOrigin.dy); _resetOriginOnLeft = false; } else if (_resetOriginOnRight && deltaPosition.dx < 0) { - _relativeOrigin = Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); + _relativeOrigin = Offset(rawCursorOffset.dx - boundingRects.right, _relativeOrigin.dy); _resetOriginOnRight = false; } if (_resetOriginOnTop && deltaPosition.dy > 0) { - _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); + _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - boundingRects.top); _resetOriginOnTop = false; } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { - _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); + _relativeOrigin = Offset(_relativeOrigin.dx, rawCursorOffset.dy - boundingRects.bottom); _resetOriginOnBottom = false; } final double currentX = rawCursorOffset.dx - _relativeOrigin.dx; final double currentY = rawCursorOffset.dy - _relativeOrigin.dy; - final double adjustedX = math.min(math.max(currentX, leftBound), rightBound); - final double adjustedY = math.min(math.max(currentY, topBound), bottomBound); - final Offset adjustedOffset = Offset(adjustedX, adjustedY); + final Offset adjustedOffset = _calculateAdjustedCursorOffset(Offset(currentX, currentY), boundingRects); - if (currentX < leftBound && deltaPosition.dx < 0) { + if (currentX < boundingRects.left && deltaPosition.dx < 0) { _resetOriginOnLeft = true; - } else if (currentX > rightBound && deltaPosition.dx > 0) { + } else if (currentX > boundingRects.right && deltaPosition.dx > 0) { _resetOriginOnRight = true; } - if (currentY < topBound && deltaPosition.dy < 0) { + if (currentY < boundingRects.top && deltaPosition.dy < 0) { _resetOriginOnTop = true; - } else if (currentY > bottomBound && deltaPosition.dy > 0) { + } else if (currentY > boundingRects.bottom && deltaPosition.dy > 0) { _resetOriginOnBottom = true; } @@ -2420,9 +2441,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, /// Sets the screen position of the floating cursor and the text position /// closest to the cursor. void setFloatingCursor(FloatingCursorDragState state, Offset boundedOffset, TextPosition lastTextPosition, { double? resetLerpValue }) { - if (state == FloatingCursorDragState.Start) { + if (state == FloatingCursorDragState.End) { _relativeOrigin = Offset.zero; _previousOffset = null; + _shouldResetOrigin = true; _resetOriginOnBottom = false; _resetOriginOnTop = false; _resetOriginOnRight = false; @@ -2898,6 +2920,12 @@ class _CaretPainter extends RenderEditablePainter { void paintRegularCursor(Canvas canvas, RenderEditable renderEditable, Color caretColor, TextPosition textPosition) { final Rect integralRect = renderEditable.getLocalRectForCaret(textPosition); if (shouldPaint) { + if (floatingCursorRect != null) { + final double distanceSquared = (floatingCursorRect!.center - integralRect.center).distanceSquared; + if (distanceSquared < _kShortestDistanceSquaredWithFloatingAndRegularCursors) { + return; + } + } final Radius? radius = cursorRadius; caretPaint.color = caretColor; if (radius == null) { diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 38dd2fc0347..27712c7ad28 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -744,12 +744,18 @@ class RawFloatingCursorPoint { /// [FloatingCursorDragState.Update]. RawFloatingCursorPoint({ this.offset, + this.startLocation, required this.state, }) : assert(state != FloatingCursorDragState.Update || offset != null); /// The raw position of the floating cursor as determined by the iOS sdk. final Offset? offset; + /// Represents the starting location when initiating a floating cursor via long press. + /// This is a tuple where the first item is the local offset and the second item is the new caret position. + /// This is only non-null when a floating cursor is started. + final (Offset, TextPosition)? startLocation; + /// The state of the floating cursor. final FloatingCursorDragState state; } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 73817d2de00..8f6611370a2 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -3162,7 +3162,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } // The original position of the caret on FloatingCursorDragState.start. - Rect? _startCaretRect; + Offset? _startCaretCenter; // The most recent text position as determined by the location of the floating // cursor. @@ -3197,15 +3197,26 @@ class EditableTextState extends State with AutomaticKeepAliveClien // we cache the position. _pointOffsetOrigin = point.offset; - final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset, affinity: renderEditable.selection!.affinity); - _startCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); + final Offset startCaretCenter; + final TextPosition currentTextPosition; + final bool shouldResetOrigin; + // Only non-null when starting a floating cursor via long press. + if (point.startLocation != null) { + shouldResetOrigin = false; + (startCaretCenter, currentTextPosition) = point.startLocation!; + } else { + shouldResetOrigin = true; + currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset, affinity: renderEditable.selection!.affinity); + startCaretCenter = renderEditable.getLocalRectForCaret(currentTextPosition).center; + } - _lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset; + _startCaretCenter = startCaretCenter; + _lastBoundedOffset = renderEditable.calculateBoundedFloatingCursorOffset(_startCaretCenter! - _floatingCursorOffset, shouldResetOrigin: shouldResetOrigin); _lastTextPosition = currentTextPosition; renderEditable.setFloatingCursor(point.state, _lastBoundedOffset!, _lastTextPosition!); case FloatingCursorDragState.Update: final Offset centeredPoint = point.offset! - _pointOffsetOrigin!; - final Offset rawCursorOffset = _startCaretRect!.center + centeredPoint - _floatingCursorOffset; + final Offset rawCursorOffset = _startCaretCenter! + centeredPoint - _floatingCursorOffset; _lastBoundedOffset = renderEditable.calculateBoundedFloatingCursorOffset(rawCursorOffset); _lastTextPosition = renderEditable.getPositionForPoint(renderEditable.localToGlobal(_lastBoundedOffset! + _floatingCursorOffset)); @@ -3245,7 +3256,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // The cause is technically the force cursor, but the cause is listed as tap as the desired functionality is the same. _handleSelectionChanged(TextSelection.fromPosition(_lastTextPosition!), SelectionChangedCause.forcePress); } - _startCaretRect = null; + _startCaretCenter = null; _lastTextPosition = null; _pointOffsetOrigin = null; _lastBoundedOffset = null; diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index a8c47cd61cb..b3c59c9cf7f 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -2453,6 +2453,19 @@ class TextSelectionGestureDetectorBuilder { from: details.globalPosition, cause: SelectionChangedCause.longPress, ); + // Show the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Start, + startLocation: ( + renderEditable.globalToLocal(details.globalPosition), + TextPosition( + offset: editableText.textEditingValue.selection.baseOffset, + affinity: editableText.textEditingValue.selection.affinity, + ), + ), + offset: Offset.zero, + ); + editableText.updateFloatingCursor(cursorPoint); } case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -2488,7 +2501,6 @@ class TextSelectionGestureDetectorBuilder { 0.0, _scrollPosition - _dragStartScrollOffset, ); - switch (defaultTargetPlatform) { case TargetPlatform.iOS: case TargetPlatform.macOS: @@ -2503,6 +2515,12 @@ class TextSelectionGestureDetectorBuilder { from: details.globalPosition, cause: SelectionChangedCause.longPress, ); + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Update, + offset: details.offsetFromOrigin, + ); + editableText.updateFloatingCursor(cursorPoint); } case TargetPlatform.android: case TargetPlatform.fuchsia: @@ -2536,6 +2554,13 @@ class TextSelectionGestureDetectorBuilder { _longPressStartedWithoutFocus = false; _dragStartViewportOffset = 0.0; _dragStartScrollOffset = 0.0; + if (defaultTargetPlatform == TargetPlatform.iOS && delegate.selectionEnabled && editableText.textEditingValue.selection.isCollapsed) { + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.End + ); + editableText.updateFloatingCursor(cursorPoint); + } } /// Handler for [TextSelectionGestureDetector.onSecondaryTap]. diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 9b604f65485..abed3ed012a 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -12,7 +12,7 @@ import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Color; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind, kDoubleTapTimeout, kSecondaryMouseButton; +import 'package:flutter/gestures.dart' show DragStartBehavior, PointerDeviceKind, kDoubleTapTimeout, kLongPressTimeout, kSecondaryMouseButton; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -279,7 +279,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -329,7 +329,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -377,7 +377,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -1749,7 +1749,7 @@ void main() { await tester.longPressAt( tester.getTopRight(find.text("j'aime la poutine")), ); - await tester.pump(); + await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 200)); Text text = tester.widget(find.text('Paste')); @@ -1780,7 +1780,7 @@ void main() { await tester.longPressAt( tester.getTopRight(find.text("j'aime la poutine")), ); - await tester.pump(); + await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 200)); text = tester.widget(find.text('Paste')); @@ -1816,7 +1816,7 @@ void main() { // Long press to put the cursor after the "w". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection.collapsed(offset: index), @@ -1863,7 +1863,7 @@ void main() { // Long press to select 'Atwater' const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -1922,7 +1922,7 @@ void main() { tester.getTopRight(find.text('readonly')), ); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Paste'), findsNothing); expect(find.text('Cut'), findsNothing); @@ -1962,7 +1962,7 @@ void main() { await tester.longPressAt( tester.getTopRight(find.text("j'aime la poutine")), ); - await tester.pump(); + await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.text('Select All')); @@ -2244,7 +2244,7 @@ void main() { // Long press to select 'Atwater'. const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection(baseOffset: 0, extentOffset: 7), @@ -2304,7 +2304,7 @@ void main() { // Long press to put the cursor after the "w". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); expect( controller.selection, const TextSelection.collapsed(offset: index), @@ -2349,7 +2349,7 @@ void main() { // Long press to put the cursor after the "w". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Second tap doesn't select anything. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -2993,7 +2993,7 @@ void main() { await tester.tapAt(textFieldStart + const Offset(150.0, 5.0)); await tester.pump(const Duration(milliseconds: 50)); await tester.longPressAt(textFieldStart + const Offset(150.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Should only have paste option when whole obscure text is selected. expect(find.text('Paste'), findsOneWidget); @@ -3007,7 +3007,7 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); // Long tap at the end. await tester.longPressAt(textFieldEnd + const Offset(-10.0, 5.0)); - await tester.pump(); + await tester.pumpAndSettle(); // Should have paste and select all options when collapse. expect(find.text('Paste'), findsOneWidget); @@ -3110,7 +3110,7 @@ void main() { final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' await tester.longPressAt(ePos); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); expectCupertinoToolbarForCollapsedSelection(); @@ -3524,7 +3524,7 @@ void main() { final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater' await tester.longPressAt(wPos); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); expect(controller.selection.isCollapsed, isTrue); expect(controller.selection.baseOffset, 3); @@ -7986,7 +7986,7 @@ void main() { final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); - await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(const Duration(milliseconds: 150)); // Tap the Select All button. await tester.tapAt(textFieldStart + const Offset(20.0, 100.0)); await tester.pump(const Duration(milliseconds: 300)); @@ -8040,7 +8040,7 @@ void main() { final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0)); - await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(const Duration(milliseconds: 150)); // Tap the Select All button. await tester.tapAt(textFieldStart + const Offset(20.0, 100.0)); await tester.pump(const Duration(milliseconds: 300)); @@ -10094,4 +10094,45 @@ void main() { // placeholder. expect(rectWithText.height, greaterThan(100)); }); + + testWidgets('Start the floating cursor on long tap', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final TextEditingController controller = TextEditingController( + text: 'abcd', + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: const ValueKey(1), + child: CupertinoTextField( + autofocus: true, + controller: controller, + ), + ) + ), + ), + ), + ); + // Wait for autofocus. + await tester.pumpAndSettle(); + final Offset textFieldCenter = tester.getCenter(find.byType(CupertinoTextField)); + final TestGesture gesture = await tester.startGesture(textFieldCenter); + await tester.pump(kLongPressTimeout); + await expectLater( + find.byKey(const ValueKey(1)), + matchesGoldenFile('text_field_floating_cursor.regular_and_floating_both.cupertino.0.png'), + ); + await gesture.moveTo(Offset(10, textFieldCenter.dy)); + await tester.pump(); + await expectLater( + find.byKey(const ValueKey(1)), + matchesGoldenFile('text_field_floating_cursor.only_floating_cursor.cupertino.0.png'), + ); + await gesture.up(); + EditableText.debugDeterministicCursor = false; + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 23d6fede6c2..f41dcbda852 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -512,7 +512,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -561,7 +561,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -610,7 +610,7 @@ void main() { // Long press to put the cursor after the "s". const int index = 3; await tester.longPressAt(textOffsetToPosition(tester, index)); - await tester.pump(); + await tester.pumpAndSettle(); // Double tap on the same location to select the word around the cursor. await tester.tapAt(textOffsetToPosition(tester, index)); @@ -1244,7 +1244,7 @@ void main() { final Offset textfieldStart = tester.getTopLeft(find.byKey(const Key('field0'))); await tester.longPressAt(textfieldStart + const Offset(50.0, 2.0)); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); await tester.tapAt(textfieldStart + const Offset(100.0, 107.0)); await tester.pump(const Duration(milliseconds: 300)); @@ -1715,7 +1715,7 @@ void main() { // Long press the 'e' to select 'def'. final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); await tester.longPressAt(ePos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); // 'def' is selected. expect(controller.selection.baseOffset, testValue.indexOf('d')); @@ -2174,7 +2174,7 @@ void main() { // Long press the 'e' to select 'def'. final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e')); await tester.longPressAt(ePos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(controller.selection.isCollapsed, true); expect(controller.selection.baseOffset, testValue.length); @@ -4209,7 +4209,7 @@ void main() { // Long press does select text. final Offset ePos = textOffsetToPosition(tester, 1); await tester.longPressAt(ePos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(controller.selection.isCollapsed, false); }); @@ -4237,7 +4237,7 @@ void main() { // Long press doesn't select text. final Offset ePos2 = textOffsetToPosition(tester, 1); await tester.longPressAt(ePos2, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(controller.selection.isCollapsed, true); }); @@ -4265,7 +4265,7 @@ void main() { // Long press doesn't select text. final Offset ePos2 = textOffsetToPosition(tester, 1); await tester.longPressAt(ePos2, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); expect(controller.selection.isCollapsed, true); }); @@ -4284,7 +4284,7 @@ void main() { // Long press does select text. final Offset bPos = textOffsetToPosition(tester, 1); await tester.longPressAt(bPos, pointer: 7); - await tester.pump(); + await tester.pumpAndSettle(); final TextSelection selection = controller.selection; expect(selection.isCollapsed, false); expect(selection.baseOffset, 0); @@ -11418,7 +11418,7 @@ void main() { // Long press again keeps the selection menu visible. await tester.longPressAt(textOffsetToPosition(tester, 0)); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.text('Paste'), findsOneWidget); }, skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu., @@ -11769,7 +11769,7 @@ void main() { final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r' await tester.longPressAt(ePos); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); // Tap slightly behind the previous tap to avoid tapping the context menu // on desktop. @@ -12737,7 +12737,7 @@ void main() { final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater' await tester.longPressAt(wPos); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); expect( controller.selection, const TextSelection.collapsed(offset: 3), @@ -17355,6 +17355,48 @@ void main() { }, skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu. ); + + + testWidgets('Start the floating cursor on long tap', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final TextEditingController controller = _textEditingController( + text: 'abcd', + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: const ValueKey(1), + child: TextField( + autofocus: true, + controller: controller, + ), + ) + ), + ), + ), + ); + // Wait for autofocus. + await tester.pumpAndSettle(); + final Offset textFieldCenter = tester.getCenter(find.byType(TextField)); + final TestGesture gesture = await tester.startGesture(textFieldCenter); + await tester.pump(kLongPressTimeout); + await expectLater( + find.byKey(const ValueKey(1)), + matchesGoldenFile('text_field_floating_cursor.regular_and_floating_both.material.0.png'), + ); + await gesture.moveTo(Offset(10, textFieldCenter.dy)); + await tester.pump(); + await expectLater( + find.byKey(const ValueKey(1)), + matchesGoldenFile('text_field_floating_cursor.only_floating_cursor.material.0.png'), + ); + await gesture.up(); + EditableText.debugDeterministicCursor = false; + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); } /// A Simple widget for testing the obscure text. diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart index 883f98f515a..033b2ca0ace 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -957,13 +957,6 @@ void main() { await tester.pump(); expect(editable, paints - ..rrect( - rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(463.3333435058594, -0.916666666666668, 465.3333435058594, 17.083333015441895), - const Radius.circular(2.0), - ), - color: const Color(0xff999999), - ) ..rrect( rrect: RRect.fromRectAndRadius( const Rect.fromLTRB(463.8333435058594, -0.916666666666668, 466.8333435058594, 19.083333969116211), @@ -984,14 +977,32 @@ void main() { expect(find.byType(EditableText), paints ..rrect( rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(191.3333282470703, -0.916666666666668, 193.3333282470703, 17.083333015441895), + const Rect.fromLTRB(193.83334350585938, -0.916666666666668, 196.83334350585938, 19.083333969116211), + const Radius.circular(1.0), + ), + color: const Color(0xbf2196f3), + ), + ); + + // Move the cursor away from characters, this will show the regular cursor. + editableTextState.updateFloatingCursor( + RawFloatingCursorPoint( + state: FloatingCursorDragState.Update, + offset: const Offset(800, 0), + ), + ); + + expect(find.byType(EditableText), paints + ..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTRB(719.3333333333333, -0.9166666666666679, 721.3333333333333, 17.083333333333332), const Radius.circular(2.0), ), color: const Color(0xff999999), ) ..rrect( rrect: RRect.fromRectAndRadius( - const Rect.fromLTRB(193.83334350585938, -0.916666666666668, 196.83334350585938, 19.083333969116211), + const Rect.fromLTRB(800.5, -5.0, 803.5, 15.0), const Radius.circular(1.0), ), color: const Color(0xbf2196f3), @@ -1302,4 +1313,88 @@ void main() { }, variant: TargetPlatformVariant.all(), ); + + testWidgets('Floating cursor showing with local position', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final GlobalKey key = GlobalKey(); + controller.text = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ\n1234567890'; + controller.selection = const TextSelection.collapsed(offset: 0); + + await tester.pumpWidget( + MaterialApp( + home: EditableText( + key: key, + autofocus: true, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + maxLines: 2, + ), + ), + ); + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateFloatingCursor( + RawFloatingCursorPoint( + state: FloatingCursorDragState.Start, + offset: Offset.zero, + startLocation: (Offset.zero, TextPosition(offset: controller.selection.baseOffset, affinity: controller.selection.affinity)) + ) + ); + await tester.pump(); + + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(-0.5, -3.0, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update, offset: const Offset(51, 0))); + await tester.pump(); + + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(50.5, -3.0, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End)); + await tester.pumpAndSettle(); + + state.updateFloatingCursor( + RawFloatingCursorPoint( + state: FloatingCursorDragState.Start, + offset: Offset.zero, + startLocation: (const Offset(800, 10), TextPosition(offset: controller.selection.baseOffset, affinity: controller.selection.affinity)) + ) + ); + await tester.pump(); + + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(799.5, 4.0, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update, offset: const Offset(100, 10))); + await tester.pump(); + + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(800.5, 14.0, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End)); + await tester.pumpAndSettle(); + + EditableText.debugDeterministicCursor = false; + }); }