diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index b5a3b59ea14d..cc06f96fcb80 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -3102,7 +3102,7 @@ class _FloatingCursorPainter extends RenderEditablePainter { } canvas.drawRRect( - RRect.fromRectAndRadius(floatingCursorRect.shift(renderEditable._paintOffset), _kFloatingCaretRadius), + RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius), floatingCursorPaint..color = floatingCursorColor, ); } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e782b44881ff..94a66ee6a3f2 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2671,7 +2671,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // we cache the position. _pointOffsetOrigin = point.offset; - final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset); + final TextPosition currentTextPosition = TextPosition(offset: renderEditable.selection!.baseOffset, affinity: renderEditable.selection!.affinity); _startCaretRect = renderEditable.getLocalRectForCaret(currentTextPosition); _lastBoundedOffset = _startCaretRect!.center - _floatingCursorOffset; @@ -2702,9 +2702,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien final Offset finalPosition = renderEditable.getLocalRectForCaret(_lastTextPosition!).centerLeft - _floatingCursorOffset; if (_floatingCursorResetController!.isCompleted) { renderEditable.setFloatingCursor(FloatingCursorDragState.End, finalPosition, _lastTextPosition!); - if (_lastTextPosition!.offset != renderEditable.selection!.baseOffset) { + // Only change if new position is out of current selection range, as the + // selection may have been modified using the iOS keyboard selection gesture. + if (_lastTextPosition!.offset < renderEditable.selection!.start || _lastTextPosition!.offset >= renderEditable.selection!.end) { // The cause is technically the force cursor, but the cause is listed as tap as the desired functionality is the same. - _handleSelectionChanged(TextSelection.collapsed(offset: _lastTextPosition!.offset), SelectionChangedCause.forcePress); + _handleSelectionChanged(TextSelection.fromPosition(_lastTextPosition!), SelectionChangedCause.forcePress); } _startCaretRect = null; _lastTextPosition = null; diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index 06839cb169ec..7a96f1402737 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -12,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/src/services/text_input.dart'; import 'package:flutter_test/flutter_test.dart'; import 'mock_canvas.dart'; @@ -1725,6 +1726,79 @@ void main() { editable.forceLine = false; expect(editable.computeDryLayout(constraints).width, lessThan(initialWidth)); }); + + test('Floating cursor position is independent of viewport offset', () { + final TextSelectionDelegate delegate = _FakeEditableTextState(); + final ValueNotifier showCursor = ValueNotifier(true); + EditableText.debugDeterministicCursor = true; + + const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + + final RenderEditable editable = RenderEditable( + backgroundCursorColor: Colors.grey, + textDirection: TextDirection.ltr, + cursorColor: cursorColor, + offset: ViewportOffset.zero(), + textSelectionDelegate: delegate, + text: const TextSpan( + text: 'test', + style: TextStyle( + height: 1.0, fontSize: 10.0, fontFamily: 'Ahem', + ), + ), + maxLines: 3, + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + selection: const TextSelection.collapsed( + offset: 4, + affinity: TextAffinity.upstream, + ), + ); + + layout(editable); + + editable.layout(BoxConstraints.loose(const Size(100, 100))); + // Prepare for painting after layout. + pumpFrame(phase: EnginePhase.compositingBits); + + expect( + editable, + // Draw no cursor by default. + paintsExactlyCountTimes(#drawRect, 0), + ); + + editable.showCursor = showCursor; + editable.setFloatingCursor(FloatingCursorDragState.Start, const Offset(50, 50), const TextPosition( + offset: 4, + affinity: TextAffinity.upstream, + )); + pumpFrame(phase: EnginePhase.compositingBits); + + final RRect expectedRRect = RRect.fromRectAndRadius( + const Rect.fromLTWH(49.5, 51, 2, 8), + const Radius.circular(1) + ); + + expect(editable, paints..rrect( + color: cursorColor.withOpacity(0.75), + rrect: expectedRRect + )); + + // Change the text viewport offset. + editable.offset = ViewportOffset.fixed(200); + + // Floating cursor should be drawn in the same position. + editable.setFloatingCursor(FloatingCursorDragState.Start, const Offset(50, 50), const TextPosition( + offset: 4, + affinity: TextAffinity.upstream, + )); + pumpFrame(phase: EnginePhase.compositingBits); + + expect(editable, paints..rrect( + color: cursorColor.withOpacity(0.75), + rrect: expectedRRect + )); + }); } class _TestRenderEditable extends RenderEditable { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 5d57ab462791..818b298f5158 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -11701,6 +11701,163 @@ void main() { expect(tester.hasRunningAnimations, isFalse); }); + testWidgets('Floating cursor affinity', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final FocusNode focusNode = FocusNode(); + final GlobalKey key = GlobalKey(); + // Set it up so that there will be word-wrap. + final TextEditingController controller = TextEditingController(text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz'); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 500, + ), + child: EditableText( + key: key, + autofocus: true, + maxLines: 2, + controller: controller, + focusNode: focusNode, + style: textStyle, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + cursorOpacityAnimates: true, + ), + ), + ), + ), + ); + + await tester.pump(); + final EditableTextState state = tester.state(find.byType(EditableText)); + + // Select after the first word, with default affinity (downstream). + controller.selection = const TextSelection.collapsed(offset: 27); + await tester.pump(); + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start, offset: Offset.zero)); + await tester.pump(); + + // The floating cursor should be drawn at the end of the first line. + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.5, 15, 3, 12), + const Radius.circular(1) + ) + )); + + // Select after the first word, with upstream affinity. + controller.selection = const TextSelection.collapsed(offset: 27, affinity: TextAffinity.upstream); + await tester.pump(); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start, offset: Offset.zero)); + await tester.pump(); + + // The floating cursor should be drawn at the beginning of the second line. + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(378.5, 1, 3, 12), + const Radius.circular(1) + ) + )); + + EditableText.debugDeterministicCursor = false; + }); + +testWidgets('Floating cursor ending with selection', (WidgetTester tester) async { + EditableText.debugDeterministicCursor = true; + final FocusNode focusNode = FocusNode(); + final GlobalKey key = GlobalKey(); + // Set it up so that there will be word-wrap. + final TextEditingController controller = TextEditingController(text: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + 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, + ), + ), + ); + + await tester.pump(); + final EditableTextState state = tester.state(find.byType(EditableText)); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start, offset: Offset.zero)); + await tester.pump(); + + // The floating cursor should be drawn at the start of the line. + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.5, 1, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update, offset: const Offset(50, 0))); + await tester.pump(); + + // The floating cursor should be drawn somewhere in the middle of the line + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(50.5, 1, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End, offset: Offset.zero)); + await tester.pumpAndSettle(const Duration(milliseconds: 125)); // Floating cursor has an end animation. + + // Selection should be updated based on the floating cursor location. + expect(controller.selection.isCollapsed, true); + expect(controller.selection.baseOffset, 4); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start, offset: Offset.zero)); + await tester.pump(); + + // The floating cursor should be drawn near to the previous position. + // It's different because it's snapped to exactly between characters. + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(56.5, 1, 3, 12), + const Radius.circular(1) + ) + )); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Update, offset: const Offset(-56, 0))); + await tester.pump(); + + // The floating cursor should be drawn at the start of the line. + expect(key.currentContext!.findRenderObject(), paints..rrect( + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH(0.5, 1, 3, 12), + const Radius.circular(1) + ) + )); + + // Simulate UIKit setting the selection using keyboard selection. + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 4); + await tester.pump(); + + state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End, offset: Offset.zero)); + await tester.pump(); + + // Selection should not be updated as the new position is within the selection range. + expect(controller.selection.isCollapsed, false); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, 4); + + EditableText.debugDeterministicCursor = false; + }); + + group('Selection changed scroll into view', () { final String text = List.generate(64, (int index) => index).join('\n'); final TextEditingController controller = TextEditingController(text: text);