diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index dd308fb6627a8..825b8cf9a4b73 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -582,11 +582,12 @@ class TextSelectionOverlay { if (!renderObject.attached) { return; } - final Size handleSize = selectionControls!.getHandleSize( - renderObject.preferredLineHeight, - ); - _dragEndPosition = details.globalPosition + Offset(0.0, -handleSize.height); + // This adjusts for the fact that the selection handles may not + // perfectly cover the TextPosition that they correspond to. + final Offset offsetFromHandleToTextPosition = _getOffsetToTextPositionPoint(_selectionOverlay.endHandleType); + _dragEndPosition = details.globalPosition + offsetFromHandleToTextPosition; + final TextPosition position = renderObject.getPositionForPoint(_dragEndPosition); _selectionOverlay.showMagnifier( @@ -660,10 +661,12 @@ class TextSelectionOverlay { if (!renderObject.attached) { return; } - final Size handleSize = selectionControls!.getHandleSize( - renderObject.preferredLineHeight, - ); - _dragStartPosition = details.globalPosition + Offset(0.0, -handleSize.height); + + // This adjusts for the fact that the selection handles may not + // perfectly cover the TextPosition that they correspond to. + final Offset offsetFromHandleToTextPosition = _getOffsetToTextPositionPoint(_selectionOverlay.startHandleType); + _dragStartPosition = details.globalPosition + offsetFromHandleToTextPosition; + final TextPosition position = renderObject.getPositionForPoint(_dragStartPosition); _selectionOverlay.showMagnifier( @@ -731,6 +734,32 @@ class TextSelectionOverlay { void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed); + // Returns the offset that locates a drag on a handle to the correct line of text. + Offset _getOffsetToTextPositionPoint(TextSelectionHandleType type) { + final Size handleSize = selectionControls!.getHandleSize( + renderObject.preferredLineHeight, + ); + + // Try to shift center of handle to top by half of handle height. + final double halfHandleHeight = handleSize.height / 2; + + // [getHandleAnchor] is used to shift the selection endpoint to the top left + // point of the handle rect when building the handle widget. + // The endpoint is at the bottom of the selection rect, which is also at the + // bottom of the line of text. + // Try to shift the top of the handle to the selection endpoint by the dy of + // the handle's anchor. + final double handleAnchorDy = selectionControls!.getHandleAnchor(type, renderObject.preferredLineHeight).dy; + + // Try to shift the selection endpoint to the center of the correct line by + // using half of the line height. + final double halfPreferredLineHeight = renderObject.preferredLineHeight / 2; + + // The x offset is accurate, so we only need to adjust the y position. + final double offsetYFromHandleToTextPosition = handleAnchorDy - halfHandleHeight - halfPreferredLineHeight; + return Offset(0.0, offsetYFromHandleToTextPosition); + } + void _handleSelectionHandleChanged(TextSelection newSelection, {required bool isEnd}) { final TextPosition textPosition = isEnd ? newSelection.extent : newSelection.base; selectionDelegate.userUpdateTextEditingValue( diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index fe2b8521a5b1f..f646deec9186d 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -6805,4 +6805,101 @@ void main() { }, variant: TargetPlatformVariant.all()); }); }); + + testWidgets('Can drag handles to change selection correctly in multiline', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController(); + + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: CupertinoTextField( + dragStartBehavior: DragStartBehavior.down, + controller: controller, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 3, + ), + ), + ), + ); + + const String testValue = + 'First line of text is\n' + 'Second line goes until\n' + 'Third line of stuff'; + + const String cutValue = + 'First line of text is\n' + 'Second until\n' + 'Third line of stuff'; + await tester.enterText(find.byType(CupertinoTextField), testValue); + + // Skip past scrolling animation. + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + + // Check that the text spans multiple lines. + final Offset firstPos = textOffsetToPosition(tester, testValue.indexOf('First')); + final Offset secondPos = textOffsetToPosition(tester, testValue.indexOf('Second')); + final Offset thirdPos = textOffsetToPosition(tester, testValue.indexOf('Third')); + expect(firstPos.dx, secondPos.dx); + expect(firstPos.dx, thirdPos.dx); + expect(firstPos.dy, lessThan(secondPos.dy)); + expect(secondPos.dy, lessThan(thirdPos.dy)); + + // Double tap on the 'n' in 'until' to select the word. + final Offset untilPos = textOffsetToPosition(tester, testValue.indexOf('until')+1); + await tester.tapAt(untilPos); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(untilPos); + await tester.pumpAndSettle(); + + // Skip past the frame where the opacity is zero. + await tester.pump(const Duration(milliseconds: 200)); + + expect(controller.selection.baseOffset, 39); + expect(controller.selection.extentOffset, 44); + + final RenderEditable renderEditable = findRenderEditable(tester); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection(controller.selection), + renderEditable, + ); + expect(endpoints.length, 2); + + final Offset offsetFromEndPointToMiddlePoint = Offset(0.0, -renderEditable.preferredLineHeight / 2); + + // Drag the left handle to just after 'Second', still on the second line. + Offset handlePos = endpoints[0].point + offsetFromEndPointToMiddlePoint; + Offset newHandlePos = textOffsetToPosition(tester, testValue.indexOf('Second') + 6) + offsetFromEndPointToMiddlePoint; + 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, 28); + expect(controller.selection.extentOffset, 44); + + // Drag the right handle to just after 'goes', still on the second line. + handlePos = endpoints[1].point + offsetFromEndPointToMiddlePoint; + newHandlePos = textOffsetToPosition(tester, testValue.indexOf('goes') + 4) + offsetFromEndPointToMiddlePoint; + 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, 28); + expect(controller.selection.extentOffset, 38); + + if (!isContextMenuProvidedByPlatform) { + await tester.tap(find.text('Cut')); + await tester.pump(); + expect(controller.selection.isCollapsed, true); + expect(controller.text, cutValue); + } + }); }