diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index 51acd0e41085e..5af74609bc5f0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -63,9 +63,11 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { @interface FlutterTextPosition : UITextPosition @property(nonatomic, readonly) NSUInteger index; +@property(nonatomic, readonly) UITextStorageDirection affinity; + (instancetype)positionWithIndex:(NSUInteger)index; -- (instancetype)initWithIndex:(NSUInteger)index; ++ (instancetype)positionWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity; +- (instancetype)initWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity; @end @@ -100,6 +102,10 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { + (instancetype)selectionRectWithRect:(CGRect)rect position:(NSUInteger)position; ++ (instancetype)selectionRectWithRect:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection; + - (instancetype)initWithRectAndInfo:(CGRect)rect position:(NSUInteger)position writingDirection:(NSWritingDirection)writingDirection @@ -108,6 +114,8 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { isVertical:(BOOL)isVertical; - (instancetype)init NS_UNAVAILABLE; + +- (BOOL)isRTL; @end API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder : UITextPlaceholder diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index e74a1640bf197..b0984113a3fbb 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -26,18 +26,6 @@ // returns kInvalidFirstRect, iOS will not show the IME candidates view. const CGRect kInvalidFirstRect = {{-1, -1}, {9999, 9999}}; -// The `bounds` value a FlutterTextInputView returns when the floating cursor -// is activated in that view. -// -// DO NOT use extremely large values (such as CGFloat_MAX) in this rect, for that -// will significantly reduce the precision of the floating cursor's coordinates. -// -// It is recommended for this CGRect to be roughly centered at caretRectForPosition -// (which currently always return CGRectZero), so the initial floating cursor will -// be placed at (0, 0). -// See the comments in beginFloatingCursorAtPoint and caretRectForPosition. -const CGRect kSpacePanBounds = {{-2500, -2500}, {5000, 5000}}; - #pragma mark - TextInput channel method names. // See https://api.flutter.dev/flutter/services/SystemChannels/textInput-constant.html static NSString* const kShowMethod = @"TextInput.show"; @@ -439,44 +427,66 @@ static BOOL IsApproximatelyEqual(float x, float y, float delta) { // Checks whether point should be considered closer to selectionRect compared to // otherSelectionRect. // -// If checkRightBoundary is set, the right-center point on selectionRect and -// otherSelectionRect will be used instead of the left-center point. +// Uses the leading-center point on selectionRect and otherSelectionRect to compare. +// For left-to-right text, this means the left-center point, and for right-to-left text, +// this means the right-center point. +// +// If useTrailingBoundaryOfSelectionRect is set, the trailing-center point on selectionRect +// will be used instead of the leading-center point. // // This uses special (empirically determined using a 1st gen iPad pro, 9.7" model running // iOS 14.7.1) logic for determining the closer rect, rather than a simple distance calculation. // First, the closer vertical distance is determined. Within the closest y distance, if the point is // above the bottom of the closest rect, the x distance will be minimized; however, if the point is // below the bottom of the rect, the x value will be maximized. -static BOOL IsSelectionRectCloserToPoint(CGPoint point, - CGRect selectionRect, - CGRect otherSelectionRect, - BOOL checkRightBoundary) { - CGPoint pointForSelectionRect = - CGPointMake(selectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0), - selectionRect.origin.y + selectionRect.size.height * 0.5); +static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, + CGRect selectionRect, + BOOL selectionRectIsRTL, + BOOL useTrailingBoundaryOfSelectionRect, + CGRect otherSelectionRect, + BOOL otherSelectionRectIsRTL, + CGFloat verticalPrecision) { + if (CGRectContainsPoint( + CGRectMake( + selectionRect.origin.x + ((useTrailingBoundaryOfSelectionRect ^ selectionRectIsRTL) + ? 0.5 * selectionRect.size.width + : 0), + selectionRect.origin.y, 0.5 * selectionRect.size.width, selectionRect.size.height), + point)) { + return YES; + } + CGPoint pointForSelectionRect = CGPointMake( + selectionRect.origin.x + + (selectionRectIsRTL ^ useTrailingBoundaryOfSelectionRect ? selectionRect.size.width : 0), + selectionRect.origin.y + selectionRect.size.height * 0.5); float yDist = fabs(pointForSelectionRect.y - point.y); float xDist = fabs(pointForSelectionRect.x - point.x); - CGPoint pointForOtherSelectionRect = - CGPointMake(otherSelectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0), - otherSelectionRect.origin.y + otherSelectionRect.size.height * 0.5); + CGPoint pointForOtherSelectionRect = CGPointMake( + otherSelectionRect.origin.x + (otherSelectionRectIsRTL ? otherSelectionRect.size.width : 0), + otherSelectionRect.origin.y + otherSelectionRect.size.height * 0.5); float yDistOther = fabs(pointForOtherSelectionRect.y - point.y); float xDistOther = fabs(pointForOtherSelectionRect.x - point.x); // This serves a similar purpose to IsApproximatelyEqual, allowing a little buffer before // declaring something closer vertically to account for the small variations in size and position // of SelectionRects, especially when dealing with emoji. - BOOL isCloserVertically = yDist < yDistOther - 1; - BOOL isEqualVertically = IsApproximatelyEqual(yDist, yDistOther, 1); + BOOL isCloserVertically = yDist < yDistOther - verticalPrecision; + BOOL isEqualVertically = IsApproximatelyEqual(yDist, yDistOther, verticalPrecision); BOOL isAboveBottomOfLine = point.y <= selectionRect.origin.y + selectionRect.size.height; - BOOL isCloserHorizontally = xDist <= xDistOther; + BOOL isCloserHorizontally = xDist < xDistOther; BOOL isBelowBottomOfLine = point.y > selectionRect.origin.y + selectionRect.size.height; - BOOL isFartherToRight = - selectionRect.origin.x + (checkRightBoundary ? selectionRect.size.width : 0) > - otherSelectionRect.origin.x; + BOOL isFarther; + if (selectionRectIsRTL) { + isFarther = selectionRect.origin.x < otherSelectionRect.origin.x; + } else { + isFarther = selectionRect.origin.x + + (useTrailingBoundaryOfSelectionRect ? selectionRect.size.width : 0) > + otherSelectionRect.origin.x; + } return (isCloserVertically || - (isEqualVertically && ((isAboveBottomOfLine && isCloserHorizontally) || - (isBelowBottomOfLine && isFartherToRight)))); + (isEqualVertically && + ((isAboveBottomOfLine && isCloserHorizontally) || (isBelowBottomOfLine && isFarther)))); } #pragma mark - FlutterTextPosition @@ -484,13 +494,18 @@ static BOOL IsSelectionRectCloserToPoint(CGPoint point, @implementation FlutterTextPosition + (instancetype)positionWithIndex:(NSUInteger)index { - return [[FlutterTextPosition alloc] initWithIndex:index]; + return [[FlutterTextPosition alloc] initWithIndex:index affinity:UITextStorageDirectionForward]; +} + ++ (instancetype)positionWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity { + return [[FlutterTextPosition alloc] initWithIndex:index affinity:affinity]; } -- (instancetype)initWithIndex:(NSUInteger)index { +- (instancetype)initWithIndex:(NSUInteger)index affinity:(UITextStorageDirection)affinity { self = [super init]; if (self) { _index = index; + _affinity = affinity; } return self; } @@ -514,11 +529,13 @@ - (instancetype)initWithNSRange:(NSRange)range { } - (UITextPosition*)start { - return [FlutterTextPosition positionWithIndex:self.range.location]; + return [FlutterTextPosition positionWithIndex:self.range.location + affinity:UITextStorageDirectionForward]; } - (UITextPosition*)end { - return [FlutterTextPosition positionWithIndex:self.range.location + self.range.length]; + return [FlutterTextPosition positionWithIndex:self.range.location + self.range.length + affinity:UITextStorageDirectionBackward]; } - (BOOL)isEmpty { @@ -628,7 +645,18 @@ + (instancetype)selectionRectWithRectAndInfo:(CGRect)rect + (instancetype)selectionRectWithRect:(CGRect)rect position:(NSUInteger)position { return [[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect position:position - writingDirection:UITextWritingDirectionNatural + writingDirection:NSWritingDirectionNatural + containsStart:NO + containsEnd:NO + isVertical:NO]; +} + ++ (instancetype)selectionRectWithRect:(CGRect)rect + position:(NSUInteger)position + writingDirection:(NSWritingDirection)writingDirection { + return [[FlutterTextSelectionRect alloc] initWithRectAndInfo:rect + position:position + writingDirection:writingDirection containsStart:NO containsEnd:NO isVertical:NO]; @@ -652,6 +680,10 @@ - (instancetype)initWithRectAndInfo:(CGRect)rect return self; } +- (BOOL)isRTL { + return _writingDirection == NSWritingDirectionRightToLeft; +} + @end #pragma mark - FlutterTextPlaceholder @@ -739,6 +771,7 @@ @implementation FlutterTextInputView { // when the app shows its own in-flutter keyboard. bool _isSystemKeyboardEnabled; bool _isFloatingCursorActive; + CGPoint _floatingCursorOffset; bool _enableInteractiveSelection; UITextInteraction* _textInteraction API_AVAILABLE(ios(13.0)); } @@ -762,7 +795,6 @@ - (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { // Initialize with the zero matrix which is not // an affine transform. _editableTransform = CATransform3D(); - _isFloatingCursorActive = false; // UITextInputTraits _autocapitalizationType = UITextAutocapitalizationTypeSentences; @@ -1389,11 +1421,12 @@ - (UITextPosition*)positionFromPosition:(UITextPosition*)position } - (UITextPosition*)beginningOfDocument { - return [FlutterTextPosition positionWithIndex:0]; + return [FlutterTextPosition positionWithIndex:0 affinity:UITextStorageDirectionForward]; } - (UITextPosition*)endOfDocument { - return [FlutterTextPosition positionWithIndex:self.text.length]; + return [FlutterTextPosition positionWithIndex:self.text.length + affinity:UITextStorageDirectionBackward]; } - (NSComparisonResult)comparePosition:(UITextPosition*)position toPosition:(UITextPosition*)other { @@ -1405,7 +1438,17 @@ - (NSComparisonResult)comparePosition:(UITextPosition*)position toPosition:(UITe if (positionIndex > otherIndex) { return NSOrderedDescending; } - return NSOrderedSame; + UITextStorageDirection positionAffinity = ((FlutterTextPosition*)position).affinity; + UITextStorageDirection otherAffinity = ((FlutterTextPosition*)other).affinity; + if (positionAffinity == otherAffinity) { + return NSOrderedSame; + } + if (positionAffinity == UITextStorageDirectionBackward) { + // positionAffinity points backwards, otherAffinity points forwards + return NSOrderedAscending; + } + // positionAffinity points forwards, otherAffinity points backwards + return NSOrderedDescending; } - (NSInteger)offsetFromPosition:(UITextPosition*)from toPosition:(UITextPosition*)toPosition { @@ -1415,17 +1458,20 @@ - (NSInteger)offsetFromPosition:(UITextPosition*)from toPosition:(UITextPosition - (UITextPosition*)positionWithinRange:(UITextRange*)range farthestInDirection:(UITextLayoutDirection)direction { NSUInteger index; + UITextStorageDirection affinity; switch (direction) { case UITextLayoutDirectionLeft: case UITextLayoutDirectionUp: index = ((FlutterTextPosition*)range.start).index; + affinity = UITextStorageDirectionForward; break; case UITextLayoutDirectionRight: case UITextLayoutDirectionDown: index = ((FlutterTextPosition*)range.end).index; + affinity = UITextStorageDirectionBackward; break; } - return [FlutterTextPosition positionWithIndex:index]; + return [FlutterTextPosition positionWithIndex:index affinity:affinity]; } - (UITextRange*)characterRangeByExtendingPosition:(UITextPosition*)position @@ -1601,23 +1647,77 @@ - (CGRect)firstRectForRange:(UITextRange*)range { return CGRectZero; } -- (CGRect)caretRectForPosition:(UITextPosition*)position { - // TODO(cbracken) Implement. - - // As of iOS 14.4, this call is used by iOS's - // _UIKeyboardTextSelectionController to determine the position - // of the floating cursor when the user force touches the space - // bar to initiate floating cursor. - // - // It is recommended to return a value that's roughly the - // center of kSpacePanBounds to make sure the floating cursor - // has ample space in all directions and does not hit kSpacePanBounds. - // See the comments in beginFloatingCursorAtPoint. - return CGRectZero; +- (BOOL)isRTLAtPosition:(NSUInteger)position { + // _selectionRects is sorted by position already. + // We can use binary search. + NSInteger min = 0; + NSInteger max = [_selectionRects count]; + while (min <= max) { + const NSUInteger mid = min + (max - min) / 2; + FlutterTextSelectionRect* rect = _selectionRects[mid]; + if (rect.position > position) { + max = mid - 1; + } else if (rect.position == position) { + return rect.isRTL; + } else { + min = mid + 1; + } + } + return NO; } -- (CGRect)bounds { - return _isFloatingCursorActive ? kSpacePanBounds : super.bounds; +- (CGRect)caretRectForPosition:(UITextPosition*)position { + NSInteger index = ((FlutterTextPosition*)position).index; + UITextStorageDirection affinity = ((FlutterTextPosition*)position).affinity; + // Get the bounds of the characters before and after the requested caret position. + NSArray* rects = [self + selectionRectsForRange:[FlutterTextRange + rangeWithNSRange:fml::RangeForCharactersInRange( + self.text, + NSMakeRange( + MAX(0, index - 1), + (index >= (NSInteger)self.text.length) + ? 1 + : 2))]]; + if (rects.count == 0) { + return CGRectZero; + } + if (index == 0) { + // There is no character before the caret, so this will be the bounds of the character after the + // caret position. + CGRect characterAfterCaret = rects[0].rect; + // Return a zero-width rectangle along the upstream edge of the character after the caret + // position. + if ([self isRTLAtPosition:index]) { + return CGRectMake(characterAfterCaret.origin.x + characterAfterCaret.size.width, + characterAfterCaret.origin.y, 0, characterAfterCaret.size.height); + } else { + return CGRectMake(characterAfterCaret.origin.x, characterAfterCaret.origin.y, 0, + characterAfterCaret.size.height); + } + } else if (rects.count == 2 && affinity == UITextStorageDirectionForward) { + // It's better to use the character after the caret. + CGRect characterAfterCaret = rects[1].rect; + // Return a zero-width rectangle along the upstream edge of the character after the caret + // position. + if ([self isRTLAtPosition:index]) { + return CGRectMake(characterAfterCaret.origin.x + characterAfterCaret.size.width, + characterAfterCaret.origin.y, 0, characterAfterCaret.size.height); + } else { + return CGRectMake(characterAfterCaret.origin.x, characterAfterCaret.origin.y, 0, + characterAfterCaret.size.height); + } + } + CGRect characterBeforeCaret = rects[0].rect; + // Return a zero-width rectangle along the downstream edge of the character before the caret + // position. + if ([self isRTLAtPosition:index - 1]) { + return CGRectMake(characterBeforeCaret.origin.x, characterBeforeCaret.origin.y, 0, + characterBeforeCaret.size.height); + } else { + return CGRectMake(characterBeforeCaret.origin.x + characterBeforeCaret.size.width, + characterBeforeCaret.origin.y, 0, characterBeforeCaret.size.height); + } } - (UITextPosition*)closestPositionToPoint:(CGPoint)point { @@ -1626,7 +1726,9 @@ - (UITextPosition*)closestPositionToPoint:(CGPoint)point { @"Expected a FlutterTextPosition for position (got %@).", [_selectedTextRange.start class]); NSUInteger currentIndex = ((FlutterTextPosition*)_selectedTextRange.start).index; - return [FlutterTextPosition positionWithIndex:currentIndex]; + UITextStorageDirection currentAffinity = + ((FlutterTextPosition*)_selectedTextRange.start).affinity; + return [FlutterTextPosition positionWithIndex:currentIndex affinity:currentAffinity]; } FlutterTextRange* range = [FlutterTextRange @@ -1649,7 +1751,9 @@ - (NSArray*)selectionRectsForRange:(UITextRange*)range { NSUInteger end = ((FlutterTextPosition*)range.end).index; NSMutableArray* rects = [[NSMutableArray alloc] init]; for (NSUInteger i = 0; i < [_selectionRects count]; i++) { - if (_selectionRects[i].position >= start && _selectionRects[i].position <= end) { + if (_selectionRects[i].position >= start && + (_selectionRects[i].position < end || + (start == end && _selectionRects[i].position <= end))) { float width = _selectionRects[i].rect.size.width; if (start == end) { width = 0; @@ -1659,7 +1763,7 @@ - (NSArray*)selectionRectsForRange:(UITextRange*)range { FlutterTextSelectionRect* selectionRect = [FlutterTextSelectionRect selectionRectWithRectAndInfo:rect position:_selectionRects[i].position - writingDirection:UITextWritingDirectionNatural + writingDirection:NSWritingDirectionNatural containsStart:(i == 0) containsEnd:(i == fml::RangeForCharactersInRange( self.text, NSMakeRange(0, self.text.length)) @@ -1679,37 +1783,47 @@ - (UITextPosition*)closestPositionToPoint:(CGPoint)point withinRange:(UITextRang NSUInteger start = ((FlutterTextPosition*)range.start).index; NSUInteger end = ((FlutterTextPosition*)range.end).index; - NSUInteger _closestIndex = 0; - CGRect _closestRect = CGRectZero; - NSUInteger _closestPosition = 0; + // Selecting text using the floating cursor is not as precise as the pencil. + // Allow further vertical deviation and base more of the decision on horizontal comparison. + CGFloat verticalPrecision = _isFloatingCursorActive ? 10 : 1; + + BOOL isFirst = YES; + NSUInteger _closestRectIndex = 0; for (NSUInteger i = 0; i < [_selectionRects count]; i++) { NSUInteger position = _selectionRects[i].position; if (position >= start && position <= end) { - BOOL isFirst = _closestIndex == 0; - if (isFirst || IsSelectionRectCloserToPoint(point, _selectionRects[i].rect, _closestRect, - /*checkRightBoundary=*/NO)) { - _closestIndex = i; - _closestRect = _selectionRects[i].rect; - _closestPosition = position; + if (isFirst || + IsSelectionRectBoundaryCloserToPoint( + point, _selectionRects[i].rect, _selectionRects[i].isRTL, + /*useTrailingBoundaryOfSelectionRect=*/NO, _selectionRects[_closestRectIndex].rect, + _selectionRects[_closestRectIndex].isRTL, verticalPrecision)) { + isFirst = NO; + _closestRectIndex = i; } } } - FlutterTextRange* textRange = [FlutterTextRange - rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))]; + FlutterTextPosition* closestPosition = + [FlutterTextPosition positionWithIndex:_selectionRects[_closestRectIndex].position + affinity:UITextStorageDirectionForward]; - if ([_selectionRects count] > 0 && textRange.range.length == end) { - NSUInteger i = [_selectionRects count] - 1; + // Check if the far side of the closest rect is a better fit (tapping end of line) + for (NSUInteger i = MAX(0, _closestRectIndex - 1); + i < MIN(_closestRectIndex + 2, [_selectionRects count]); i++) { NSUInteger position = _selectionRects[i].position + 1; - if (position <= end) { - if (IsSelectionRectCloserToPoint(point, _selectionRects[i].rect, _closestRect, - /*checkRightBoundary=*/YES)) { - _closestPosition = position; + if (position >= start && position <= end) { + if (IsSelectionRectBoundaryCloserToPoint( + point, _selectionRects[i].rect, _selectionRects[i].isRTL, + /*useTrailingBoundaryOfSelectionRect=*/YES, _selectionRects[_closestRectIndex].rect, + _selectionRects[_closestRectIndex].isRTL, verticalPrecision)) { + // This is an upstream position + closestPosition = [FlutterTextPosition positionWithIndex:position + affinity:UITextStorageDirectionBackward]; } } } - return [FlutterTextPosition positionWithIndex:_closestPosition]; + return closestPosition; } - (UITextRange*)characterRangeAtPoint:(CGPoint)point { @@ -1730,38 +1844,33 @@ - (void)beginFloatingCursorAtPoint:(CGPoint)point { // caretRectForPosition boundingBox = self.convertRect(bounds, fromView:textInputView) // bounds = self._selectionClipRect ?? self.bounds // - // It's tricky to provide accurate "bounds" and "caretRectForPosition" so it's preferred to - // bypass the clamping and implement the same clamping logic in the framework where we have easy - // access to the bounding box of the input field and the caret location. - // - // The current implementation returns kSpacePanBounds for "bounds" when - // "_isFloatingCursorActive" is true. kSpacePanBounds centers "caretRectForPosition" so the - // floating cursor has enough clearance in all directions to move around. - // // It seems impossible to use a negative "width" or "height", as the "convertRect" // call always turns a CGRect's negative dimensions into non-negative values, e.g., // (1, 2, -3, -4) would become (-2, -2, 3, 4). - _isFloatingCursorActive = true; + _isFloatingCursorActive = YES; + _floatingCursorOffset = point; [self.textInputDelegate flutterTextInputView:self updateFloatingCursor:FlutterFloatingCursorDragStateStart withClient:_textInputClient - withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; + withPosition:@{@"X" : @0, @"Y" : @0}]; } - (void)updateFloatingCursorAtPoint:(CGPoint)point { - _isFloatingCursorActive = true; [self.textInputDelegate flutterTextInputView:self updateFloatingCursor:FlutterFloatingCursorDragStateUpdate withClient:_textInputClient - withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}]; + withPosition:@{ + @"X" : @(point.x - _floatingCursorOffset.x), + @"Y" : @(point.y - _floatingCursorOffset.y) + }]; } - (void)endFloatingCursor { - _isFloatingCursorActive = false; + _isFloatingCursorActive = NO; [self.textInputDelegate flutterTextInputView:self updateFloatingCursor:FlutterFloatingCursorDragStateEnd withClient:_textInputClient - withPosition:@{@"X" : @(0), @"Y" : @(0)}]; + withPosition:@{@"X" : @0, @"Y" : @0}]; } #pragma mark - UIKeyInput Overrides @@ -1857,16 +1966,19 @@ - (void)insertText:(NSString*)text { NSUInteger rectPosition = _selectionRects[i].position; if (rectPosition == insertPosition) { for (NSUInteger j = 0; j <= text.length; j++) { - [copiedRects - addObject:[FlutterTextSelectionRect selectionRectWithRect:_selectionRects[i].rect - position:rectPosition + j]]; + [copiedRects addObject:[FlutterTextSelectionRect + selectionRectWithRect:_selectionRects[i].rect + position:rectPosition + j + writingDirection:_selectionRects[i].writingDirection]]; } } else { if (rectPosition > insertPosition) { rectPosition = rectPosition + text.length; } - [copiedRects addObject:[FlutterTextSelectionRect selectionRectWithRect:_selectionRects[i].rect - position:rectPosition]]; + [copiedRects addObject:[FlutterTextSelectionRect + selectionRectWithRect:_selectionRects[i].rect + position:rectPosition + writingDirection:_selectionRects[i].writingDirection]]; } } @@ -2158,16 +2270,20 @@ - (void)updateMarkedRect:(NSDictionary*)dictionary { _activeView.markedRect = rect.size.width < 0 && rect.size.height < 0 ? kInvalidFirstRect : rect; } -- (void)setSelectionRects:(NSArray*)rects { +- (void)setSelectionRects:(NSArray*)encodedRects { NSMutableArray* rectsAsRect = - [[NSMutableArray alloc] initWithCapacity:[rects count]]; - for (NSUInteger i = 0; i < [rects count]; i++) { - NSArray* rect = rects[i]; - [rectsAsRect - addObject:[FlutterTextSelectionRect - selectionRectWithRect:CGRectMake([rect[0] floatValue], [rect[1] floatValue], - [rect[2] floatValue], [rect[3] floatValue]) - position:[rect[4] unsignedIntegerValue]]]; + [[NSMutableArray alloc] initWithCapacity:[encodedRects count]]; + for (NSUInteger i = 0; i < [encodedRects count]; i++) { + NSArray* encodedRect = encodedRects[i]; + [rectsAsRect addObject:[FlutterTextSelectionRect + selectionRectWithRect:CGRectMake([encodedRect[0] floatValue], + [encodedRect[1] floatValue], + [encodedRect[2] floatValue], + [encodedRect[3] floatValue]) + position:[encodedRect[4] unsignedIntegerValue] + writingDirection:[encodedRect[5] unsignedIntegerValue] == 1 + ? NSWritingDirectionLeftToRight + : NSWritingDirectionRightToLeft]]; } _activeView.selectionRects = rectsAsRect; } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index c0317a289c14d..04452ed9c3246 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -389,8 +389,8 @@ - (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble { - (void)testTextRangeFromPositionMatchesUITextViewBehavior { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; - FlutterTextPosition* fromPosition = [[FlutterTextPosition alloc] initWithIndex:2]; - FlutterTextPosition* toPosition = [[FlutterTextPosition alloc] initWithIndex:0]; + FlutterTextPosition* fromPosition = [FlutterTextPosition positionWithIndex:2]; + FlutterTextPosition* toPosition = [FlutterTextPosition positionWithIndex:0]; FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition toPosition:toPosition]; @@ -1365,7 +1365,9 @@ - (void)testClosestPositionToPoint { [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U], ]]; CGPoint point = CGPointMake(150, 150); - XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionBackward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); // Then, if the point is above the bottom of the closest rects vertically, get the closest x // origin @@ -1378,6 +1380,8 @@ - (void)testClosestPositionToPoint { ]]; point = CGPointMake(125, 150); XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionForward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); // However, if the point is below the bottom of the closest rects vertically, get the position // farthest to the right @@ -1389,7 +1393,9 @@ - (void)testClosestPositionToPoint { [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U], ]]; point = CGPointMake(125, 201); - XCTAssertEqual(3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionBackward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); // Also check a point at the right edge of the last selection rect [inputView setSelectionRects:@[ @@ -1400,6 +1406,69 @@ - (void)testClosestPositionToPoint { ]]; point = CGPointMake(125, 250); XCTAssertEqual(4U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionBackward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); + + // Minimize vertical distance if the difference is more than 1 point. + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 2, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 2, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + ]]; + point = CGPointMake(110, 50); + XCTAssertEqual(2U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionForward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); + + // In floating cursor mode, the vertical difference is allowed to be 10 points. + // The closest horizontal position will now win. + [inputView beginFloatingCursorAtPoint:CGPointZero]; + XCTAssertEqual(1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).index); + XCTAssertEqual(UITextStorageDirectionForward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point]).affinity); + [inputView endFloatingCursor]; +} + +- (void)testClosestPositionToPointRTL { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) + position:0U + writingDirection:NSWritingDirectionRightToLeft], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) + position:1U + writingDirection:NSWritingDirectionRightToLeft], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) + position:2U + writingDirection:NSWritingDirectionRightToLeft], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) + position:3U + writingDirection:NSWritingDirectionRightToLeft], + ]]; + FlutterTextPosition* position = + (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(275, 50)]; + XCTAssertEqual(0U, position.index); + XCTAssertEqual(UITextStorageDirectionForward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(225, 50)]; + XCTAssertEqual(1U, position.index); + XCTAssertEqual(UITextStorageDirectionBackward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(175, 50)]; + XCTAssertEqual(1U, position.index); + XCTAssertEqual(UITextStorageDirectionForward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(125, 50)]; + XCTAssertEqual(2U, position.index); + XCTAssertEqual(UITextStorageDirectionBackward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(75, 50)]; + XCTAssertEqual(2U, position.index); + XCTAssertEqual(UITextStorageDirectionForward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(25, 50)]; + XCTAssertEqual(3U, position.index); + XCTAssertEqual(UITextStorageDirectionBackward, position.affinity); + position = (FlutterTextPosition*)[inputView closestPositionToPoint:CGPointMake(-25, 50)]; + XCTAssertEqual(3U, position.index); + XCTAssertEqual(UITextStorageDirectionBackward, position.affinity); } - (void)testSelectionRectsForRange { @@ -1416,7 +1485,7 @@ - (void)testSelectionRectsForRange { ]]; // Returns the matching rects within a range - FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 2)]; XCTAssertTrue(CGRectEqualToRect(testRect0, [inputView selectionRectsForRange:range][0].rect)); XCTAssertTrue(CGRectEqualToRect(testRect1, [inputView selectionRectsForRange:range][1].rect)); XCTAssertEqual(2U, [[inputView selectionRectsForRange:range] count]); @@ -1445,6 +1514,9 @@ - (void)testClosestPositionToPointWithinRange { FlutterTextRange* range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(3, 2)] copy]; XCTAssertEqual( 3U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index); + XCTAssertEqual( + UITextStorageDirectionForward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity); // Do not return a position after the end of the range [inputView setSelectionRects:@[ @@ -1458,6 +1530,9 @@ - (void)testClosestPositionToPointWithinRange { range = [[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] copy]; XCTAssertEqual( 1U, ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).index); + XCTAssertEqual( + UITextStorageDirectionForward, + ((FlutterTextPosition*)[inputView closestPositionToPoint:point withinRange:range]).affinity); } #pragma mark - Floating Cursor - Tests @@ -1472,42 +1547,134 @@ - (void)testFloatingCursorDoesNotThrow { [inputView endFloatingCursor]; } -- (void)testBoundsForFloatingCursor { +- (void)testFloatingCursor { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{ + @"text" : @"test", + @"selectionBase" : @1, + @"selectionExtent" : @1, + }]; - CGRect initialBounds = inputView.bounds; - // Make sure the initial bounds.size is not as large. - XCTAssertLessThan(inputView.bounds.size.width, 100); - XCTAssertLessThan(inputView.bounds.size.height, 100); + FlutterTextSelectionRect* first = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U]; + FlutterTextSelectionRect* second = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:1U]; + FlutterTextSelectionRect* third = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U]; + FlutterTextSelectionRect* fourth = + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U]; + [inputView setSelectionRects:@[ first, second, third, fourth ]]; - [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)]; - CGRect bounds = inputView.bounds; - XCTAssertGreaterThan(bounds.size.width, 1000); - XCTAssertGreaterThan(bounds.size.height, 1000); + // Verify zeroth caret rect is based on left edge of first character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:0 + affinity:UITextStorageDirectionForward]], + CGRectMake(0, 0, 0, 100))); + // Since the textAffinity is downstream, the caret rect will be based on the + // left edge of the succeeding character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:1 + affinity:UITextStorageDirectionForward]], + CGRectMake(100, 100, 0, 100))); + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:2 + affinity:UITextStorageDirectionForward]], + CGRectMake(200, 200, 0, 100))); + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:3 + affinity:UITextStorageDirectionForward]], + CGRectMake(300, 300, 0, 100))); + // There is no subsequent character for the last position, so the caret rect + // will be based on the right edge of the preceding character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:4 + affinity:UITextStorageDirectionForward]], + CGRectMake(400, 300, 0, 100))); + // Verify no caret rect for out-of-range character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:5 + affinity:UITextStorageDirectionForward]], + CGRectZero)); - // Verify the caret is centered. - XCTAssertEqual( - CGRectGetMidX(bounds), - CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:1235]])); - XCTAssertEqual( - CGRectGetMidY(bounds), - CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:4567]])); + // Check caret rects again again when text affinity is upstream. + [inputView setTextInputState:@{ + @"text" : @"test", + @"selectionBase" : @2, + @"selectionExtent" : @2, + }]; + // Verify zeroth caret rect is based on left edge of first character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:0 + affinity:UITextStorageDirectionBackward]], + CGRectMake(0, 0, 0, 100))); + // Since the textAffinity is upstream, all below caret rects will be based on + // the right edge of the preceding character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:1 + affinity:UITextStorageDirectionBackward]], + CGRectMake(100, 0, 0, 100))); + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:2 + affinity:UITextStorageDirectionBackward]], + CGRectMake(200, 100, 0, 100))); + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:3 + affinity:UITextStorageDirectionBackward]], + CGRectMake(300, 200, 0, 100))); + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:4 + affinity:UITextStorageDirectionBackward]], + CGRectMake(400, 300, 0, 100))); + // Verify no caret rect for out-of-range character. + XCTAssertTrue(CGRectEqualToRect( + [inputView caretRectForPosition:[FlutterTextPosition + positionWithIndex:5 + affinity:UITextStorageDirectionBackward]], + CGRectZero)); - [inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)]; - bounds = inputView.bounds; - XCTAssertGreaterThan(bounds.size.width, 1000); - XCTAssertGreaterThan(bounds.size.height, 1000); + // Verify floating cursor updates are relative to original position, and that there is no bounds + // change. + CGRect initialBounds = inputView.bounds; + [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)]; + XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds)); + OCMVerify([engine flutterTextInputView:inputView + updateFloatingCursor:FlutterFloatingCursorDragStateStart + withClient:0 + withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"X"] isEqualToNumber:@(0)]) && + ([state[@"Y"] isEqualToNumber:@(0)]); + }]]); - // Verify the caret is centered. - XCTAssertEqual( - CGRectGetMidX(bounds), - CGRectGetMidX([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:21]])); - XCTAssertEqual( - CGRectGetMidY(bounds), - CGRectGetMidY([inputView caretRectForPosition:[FlutterTextPosition positionWithIndex:42]])); + [inputView updateFloatingCursorAtPoint:CGPointMake(456, 654)]; + XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds)); + OCMVerify([engine flutterTextInputView:inputView + updateFloatingCursor:FlutterFloatingCursorDragStateUpdate + withClient:0 + withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"X"] isEqualToNumber:@(333)]) && + ([state[@"Y"] isEqualToNumber:@(333)]); + }]]); [inputView endFloatingCursor]; XCTAssertTrue(CGRectEqualToRect(initialBounds, inputView.bounds)); + OCMVerify([engine flutterTextInputView:inputView + updateFloatingCursor:FlutterFloatingCursorDragStateEnd + withClient:0 + withPosition:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { + return ([state[@"X"] isEqualToNumber:@(0)]) && + ([state[@"Y"] isEqualToNumber:@(0)]); + }]]); } #pragma mark - UIKeyInput Overrides - Tests @@ -1922,7 +2089,7 @@ - (void)testScribbleSetSelectionRects { XCTAssertEqual(self.installedInputViews.count, 1ul); XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u); - NSArray* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, nil]; + NSArray* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, @1, nil]; NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil]; FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"Scribble.setSelectionRects"