Skip to content

Commit

Permalink
Fix an issue that Dragging the iOS text selection handles is jumpy an…
Browse files Browse the repository at this point in the history
…d iOS text selection update incorrectly. (flutter#109136)

Better text selection handle dragging experience on iOS.
  • Loading branch information
ksballetba committed Oct 25, 2022
1 parent 13cb46d commit b375b4a
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 8 deletions.
45 changes: 37 additions & 8 deletions packages/flutter/lib/src/widgets/text_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
97 changes: 97 additions & 0 deletions packages/flutter/test/cupertino/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextSelectionPoint> 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);
}
});
}

0 comments on commit b375b4a

Please sign in to comment.