Skip to content

Commit

Permalink
Floating cursor cleanup (#116746)
Browse files Browse the repository at this point in the history
* Floating cursor cleanup

* Use TextSelection.fromPosition
  • Loading branch information
moffatman authored Dec 10, 2022
1 parent cbdc763 commit c4b8046
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 4 deletions.
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/rendering/editable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3102,7 +3102,7 @@ class _FloatingCursorPainter extends RenderEditablePainter {
}

canvas.drawRRect(
RRect.fromRectAndRadius(floatingCursorRect.shift(renderEditable._paintOffset), _kFloatingCaretRadius),
RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius),
floatingCursorPaint..color = floatingCursorColor,
);
}
Expand Down
8 changes: 5 additions & 3 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2671,7 +2671,7 @@ class EditableTextState extends State<EditableText> 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;
Expand Down Expand Up @@ -2702,9 +2702,11 @@ class EditableTextState extends State<EditableText> 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;
Expand Down
74 changes: 74 additions & 0 deletions packages/flutter/test/rendering/editable_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<bool> showCursor = ValueNotifier<bool>(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 {
Expand Down
157 changes: 157 additions & 0 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>.generate(64, (int index) => index).join('\n');
final TextEditingController controller = TextEditingController(text: text);
Expand Down

0 comments on commit c4b8046

Please sign in to comment.