Skip to content

Commit b375b4a

Browse files
authored
Fix an issue that Dragging the iOS text selection handles is jumpy and iOS text selection update incorrectly. (#109136)
Better text selection handle dragging experience on iOS.
1 parent 13cb46d commit b375b4a

File tree

2 files changed

+134
-8
lines changed

2 files changed

+134
-8
lines changed

packages/flutter/lib/src/widgets/text_selection.dart

+37-8
Original file line numberDiff line numberDiff line change
@@ -582,11 +582,12 @@ class TextSelectionOverlay {
582582
if (!renderObject.attached) {
583583
return;
584584
}
585-
final Size handleSize = selectionControls!.getHandleSize(
586-
renderObject.preferredLineHeight,
587-
);
588585

589-
_dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height);
586+
// This adjusts for the fact that the selection handles may not
587+
// perfectly cover the TextPosition that they correspond to.
588+
final Offset offsetFromHandleToTextPosition = _getOffsetToTextPositionPoint(_selectionOverlay.endHandleType);
589+
_dragEndPosition = details.globalPosition + offsetFromHandleToTextPosition;
590+
590591
final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition);
591592

592593
_selectionOverlay.showMagnifier(
@@ -660,10 +661,12 @@ class TextSelectionOverlay {
660661
if (!renderObject.attached) {
661662
return;
662663
}
663-
final Size handleSize = selectionControls!.getHandleSize(
664-
renderObject.preferredLineHeight,
665-
);
666-
_dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height);
664+
665+
// This adjusts for the fact that the selection handles may not
666+
// perfectly cover the TextPosition that they correspond to.
667+
final Offset offsetFromHandleToTextPosition = _getOffsetToTextPositionPoint(_selectionOverlay.startHandleType);
668+
_dragStartPosition = details.globalPosition + offsetFromHandleToTextPosition;
669+
667670
final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition);
668671

669672
_selectionOverlay.showMagnifier(
@@ -731,6 +734,32 @@ class TextSelectionOverlay {
731734

732735
void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
733736

737+
// Returns the offset that locates a drag on a handle to the correct line of text.
738+
Offset _getOffsetToTextPositionPoint(TextSelectionHandleType type) {
739+
final Size handleSize = selectionControls!.getHandleSize(
740+
renderObject.preferredLineHeight,
741+
);
742+
743+
// Try to shift center of handle to top by half of handle height.
744+
final double halfHandleHeight = handleSize.height / 2;
745+
746+
// [getHandleAnchor] is used to shift the selection endpoint to the top left
747+
// point of the handle rect when building the handle widget.
748+
// The endpoint is at the bottom of the selection rect, which is also at the
749+
// bottom of the line of text.
750+
// Try to shift the top of the handle to the selection endpoint by the dy of
751+
// the handle's anchor.
752+
final double handleAnchorDy = selectionControls!.getHandleAnchor(type, renderObject.preferredLineHeight).dy;
753+
754+
// Try to shift the selection endpoint to the center of the correct line by
755+
// using half of the line height.
756+
final double halfPreferredLineHeight = renderObject.preferredLineHeight / 2;
757+
758+
// The x offset is accurate, so we only need to adjust the y position.
759+
final double offsetYFromHandleToTextPosition = handleAnchorDy - halfHandleHeight - halfPreferredLineHeight;
760+
return Offset(0.0, offsetYFromHandleToTextPosition);
761+
}
762+
734763
void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) {
735764
final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base;
736765
selectionDelegate.userUpdateTextEditingValue(

packages/flutter/test/cupertino/text_field_test.dart

+97
Original file line numberDiff line numberDiff line change
@@ -6805,4 +6805,101 @@ void main() {
68056805
}, variant: TargetPlatformVariant.all());
68066806
});
68076807
});
6808+
6809+
testWidgets('Can drag handles to change selection correctly in multiline', (WidgetTester tester) async {
6810+
final TextEditingController controller = TextEditingController();
6811+
6812+
await tester.pumpWidget(
6813+
CupertinoApp(
6814+
debugShowCheckedModeBanner: false,
6815+
home: CupertinoPageScaffold(
6816+
child: CupertinoTextField(
6817+
dragStartBehavior: DragStartBehavior.down,
6818+
controller: controller,
6819+
style: const TextStyle(color: Colors.black, fontSize: 34.0),
6820+
maxLines: 3,
6821+
),
6822+
),
6823+
),
6824+
);
6825+
6826+
const String testValue =
6827+
'First line of text is\n'
6828+
'Second line goes until\n'
6829+
'Third line of stuff';
6830+
6831+
const String cutValue =
6832+
'First line of text is\n'
6833+
'Second until\n'
6834+
'Third line of stuff';
6835+
await tester.enterText(find.byType(CupertinoTextField), testValue);
6836+
6837+
// Skip past scrolling animation.
6838+
await tester.pump();
6839+
await tester.pumpAndSettle(const Duration(milliseconds: 200));
6840+
6841+
// Check that the text spans multiple lines.
6842+
final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First'));
6843+
final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second'));
6844+
final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third'));
6845+
expect(firstPos.dx, secondPos.dx);
6846+
expect(firstPos.dx, thirdPos.dx);
6847+
expect(firstPos.dy, lessThan(secondPos.dy));
6848+
expect(secondPos.dy, lessThan(thirdPos.dy));
6849+
6850+
// Double tap on the 'n' in 'until' to select the word.
6851+
final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1);
6852+
await tester.tapAt(untilPos);
6853+
await tester.pump(const Duration(milliseconds: 50));
6854+
await tester.tapAt(untilPos);
6855+
await tester.pumpAndSettle();
6856+
6857+
// Skip past the frame where the opacity is zero.
6858+
await tester.pump(const Duration(milliseconds: 200));
6859+
6860+
expect(controller.selection.baseOffset, 39);
6861+
expect(controller.selection.extentOffset, 44);
6862+
6863+
final RenderEditable renderEditable = findRenderEditable(tester);
6864+
final List<TextSelectionPoint> endpoints = globalize(
6865+
renderEditable.getEndpointsForSelection(controller.selection),
6866+
renderEditable,
6867+
);
6868+
expect(endpoints.length, 2);
6869+
6870+
final Offset offsetFromEndPointToMiddlePoint = Offset(0.0, -renderEditable.preferredLineHeight / 2);
6871+
6872+
// Drag the left handle to just after 'Second', still on the second line.
6873+
Offset handlePos = endpoints[0].point + offsetFromEndPointToMiddlePoint;
6874+
Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Second') + 6) + offsetFromEndPointToMiddlePoint;
6875+
TestGesture gesture = await tester.startGesture(handlePos, pointer: 7);
6876+
await tester.pump();
6877+
await gesture.moveTo(newHandlePos);
6878+
await tester.pump();
6879+
await gesture.up();
6880+
await tester.pump();
6881+
6882+
expect(controller.selection.baseOffset, 28);
6883+
expect(controller.selection.extentOffset, 44);
6884+
6885+
// Drag the right handle to just after 'goes', still on the second line.
6886+
handlePos = endpoints[1].point + offsetFromEndPointToMiddlePoint;
6887+
newHandlePos = textOffsetToPosition(tester, testValue.indexOf('goes') + 4) + offsetFromEndPointToMiddlePoint;
6888+
gesture = await tester.startGesture(handlePos, pointer: 7);
6889+
await tester.pump();
6890+
await gesture.moveTo(newHandlePos);
6891+
await tester.pump();
6892+
await gesture.up();
6893+
await tester.pump();
6894+
6895+
expect(controller.selection.baseOffset, 28);
6896+
expect(controller.selection.extentOffset, 38);
6897+
6898+
if (!isContextMenuProvidedByPlatform) {
6899+
await tester.tap(find.text('Cut'));
6900+
await tester.pump();
6901+
expect(controller.selection.isCollapsed, true);
6902+
expect(controller.text, cutValue);
6903+
}
6904+
});
68086905
}

0 commit comments

Comments
 (0)