Fix inconsistencies when calculating start/end handle rects for selection handles (#87236)
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index 5679989..8a37d04 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -602,6 +602,7 @@
selectionControls: selectionControls,
position: position,
dragStartBehavior: dragStartBehavior,
+ selectionDelegate: selectionDelegate!,
),
);
}
@@ -695,6 +696,7 @@
required this.onSelectionHandleChanged,
required this.onSelectionHandleTapped,
required this.selectionControls,
+ required this.selectionDelegate,
this.dragStartBehavior = DragStartBehavior.start,
}) : super(key: key);
@@ -707,6 +709,7 @@
final VoidCallback? onSelectionHandleTapped;
final TextSelectionControls selectionControls;
final DragStartBehavior dragStartBehavior;
+ final TextSelectionDelegate selectionDelegate;
@override
_TextSelectionHandleOverlayState createState() => _TextSelectionHandleOverlayState();
@@ -833,34 +836,32 @@
//
// For the end handle we compute the rectangles that encompass the range
// of the last full selected grapheme cluster at the end of the selection.
+ //
+ // Only calculate start/end handle rects if the text in the previous frame
+ // is the same as the text in the current frame. This is done because
+ // widget.renderObject contains the renderEditable from the previous frame.
+ // If the text changed between the current and previous frames then
+ // widget.renderObject.getRectForComposingRange might fail. In cases where
+ // the current frame is different from the previous we fall back to
+ // widget.renderObject.preferredLineHeight.
final InlineSpan span = widget.renderObject.text!;
- final String text = span.toPlainText();
+ final String prevText = span.toPlainText();
+ final String currText = widget.selectionDelegate.textEditingValue.text;
final int firstSelectedGraphemeExtent;
final int lastSelectedGraphemeExtent;
- final TextSelection? selection = widget.renderObject.selection;
+ final TextSelection selection = widget.selection;
+ Rect? startHandleRect;
+ Rect? endHandleRect;
- if (selection != null && selection.isValid && !selection.isCollapsed) {
- final String selectedGraphemes = selection.textInside(text);
+ if (prevText == currText && selection != null && selection.isValid && !selection.isCollapsed) {
+ final String selectedGraphemes = selection.textInside(currText);
firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
assert(firstSelectedGraphemeExtent <= selectedGraphemes.length && lastSelectedGraphemeExtent <= selectedGraphemes.length);
- } else {
- // The call to selectedGraphemes.characters.first/last will throw a state
- // error if the given text is empty, so fall back to first/last character
- // range in this case.
- //
- // The call to widget.selection.textInside(text) will return a RangeError
- // for a collapsed selection, fall back to this case when that happens.
- firstSelectedGraphemeExtent = 0;
- lastSelectedGraphemeExtent = 0;
+ startHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: selection.start, end: selection.start + firstSelectedGraphemeExtent));
+ endHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: selection.end - lastSelectedGraphemeExtent, end: selection.end));
}
- final Rect? startHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: widget.selection.start, end: widget.selection.start + firstSelectedGraphemeExtent));
- final Rect? endHandleRect = widget.renderObject.getRectForComposingRange(TextRange(start: widget.selection.end - lastSelectedGraphemeExtent, end: widget.selection.end));
-
- assert(!(firstSelectedGraphemeExtent > 0 && widget.selection.isValid && !widget.selection.isCollapsed) || startHandleRect != null);
- assert(!(lastSelectedGraphemeExtent > 0 && widget.selection.isValid && !widget.selection.isCollapsed) || endHandleRect != null);
-
final Offset handleAnchor = widget.selectionControls.getHandleAnchor(
type,
widget.renderObject.preferredLineHeight,
diff --git a/packages/flutter/test/cupertino/text_selection_test.dart b/packages/flutter/test/cupertino/text_selection_test.dart
index f061588..b5a95b9 100644
--- a/packages/flutter/test/cupertino/text_selection_test.dart
+++ b/packages/flutter/test/cupertino/text_selection_test.dart
@@ -845,4 +845,90 @@
skip: isBrowser, // We do not use Flutter-rendered context menu on the Web
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
+
+ testWidgets('iOS selection handles scaling falls back to preferredLineHeight when the current frame does not match the previous', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const CupertinoApp(
+ home: Center(
+ child: SelectableText.rich(
+ TextSpan(
+ children: <InlineSpan>[
+ TextSpan(text: 'abc', style: TextStyle(fontSize: 40.0)),
+ TextSpan(text: 'def', style: TextStyle(fontSize: 50.0)),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final EditableText editableTextWidget = tester.widget(find.byType(EditableText));
+ final EditableTextState editableTextState = tester.state(find.byType(EditableText));
+ final TextEditingController controller = editableTextWidget.controller;
+
+ // Double tap to select the second word.
+ const int index = 4;
+ await tester.tapAt(textOffsetToPosition(tester, index));
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tapAt(textOffsetToPosition(tester, index));
+ await tester.pumpAndSettle();
+ expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
+ expect(controller.selection.baseOffset, 0);
+ expect(controller.selection.extentOffset, 6);
+
+ // Drag the right handle 2 letters to the right. Placing the end handle on
+ // the third word. We use a small offset because the endpoint is on the very
+ // corner of the handle.
+ final TextSelection selection = controller.selection;
+ final RenderEditable renderEditable = findRenderEditable(tester);
+ final List<TextSelectionPoint> endpoints = globalize(
+ renderEditable.getEndpointsForSelection(selection),
+ renderEditable,
+ );
+ expect(endpoints.length, 2);
+
+ final Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
+ final Offset newHandlePos = textOffsetToPosition(tester, 3);
+ final TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
+ await tester.pump();
+ await gesture.moveTo(newHandlePos);
+ await tester.pump();
+ await gesture.up();
+ await tester.pump();
+
+ expect(controller.selection.baseOffset, 0);
+ expect(controller.selection.extentOffset, 3);
+
+ // Find start and end handles and verify their sizes.
+ expect(find.byType(Overlay), findsOneWidget);
+ expect(find.descendant(
+ of: find.byType(Overlay),
+ matching: find.byType(CustomPaint),
+ ), findsNWidgets(2));
+
+ final Iterable<RenderBox> handles = tester.renderObjectList(find.descendant(
+ of: find.byType(Overlay),
+ matching: find.byType(CustomPaint),
+ ));
+
+ // The handle height is determined by the formula:
+ // textLineHeight + _kSelectionHandleRadius * 2 - _kSelectionHandleOverlap .
+ // The text line height will be the value of the fontSize.
+ // The constant _kSelectionHandleRadius has the value of 6.
+ // The constant _kSelectionHandleOverlap has the value of 1.5.
+ // In the case of the start handle, which is located on the word 'abc',
+ // 40.0 + 6 * 2 - 1.5 = 50.5 .
+ //
+ // We are now using the current frames selection and text in order to
+ // calculate the start and end handle heights (we fall back to preferredLineHeight
+ // when the current frame differs from the previous frame), where previously
+ // we would be using a mix of the previous and current frame. This could
+ // result in the start and end handle heights being calculated inaccurately
+ // if one of the handles falls between two varying text styles.
+ expect(handles.first.size.height, 50.5);
+ expect(handles.last.size.height, 50.5); // This is 60.5 with the previous frame.
+ },
+ skip: isBrowser, // We do not use Flutter-rendered context menu on the Web
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
+ );
}