diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 78ddc45602375..29a5930cae018 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -1598,6 +1598,8 @@ - (CGRect)localRectFromFrameworkTransform:(CGRect)incomingRect { // and to position the // candidates view for multi-stage input methods (e.g., Japanese) when using a // physical keyboard. +// Returns the rect for the queried range, or a subrange through the end of line, if +// the range encompasses multiple lines. - (CGRect)firstRectForRange:(UITextRange*)range { NSAssert([range.start isKindOfClass:[FlutterTextPosition class]], @"Expected a FlutterTextPosition for range.start (got %@).", [range.start class]); @@ -1652,6 +1654,14 @@ - (CGRect)firstRectForRange:(UITextRange*)range { if (end < start) { first = end; } + + CGRect startSelectionRect = CGRectNull; + CGRect endSelectionRect = CGRectNull; + // Selection rects from different langauges may have different minY/maxY. + // So we need to iterate through each rects to update minY/maxY. + CGFloat minY = CGFLOAT_MAX; + CGFloat maxY = CGFLOAT_MIN; + FlutterTextRange* textRange = [FlutterTextRange rangeWithNSRange:fml::RangeForCharactersInRange(self.text, NSMakeRange(0, self.text.length))]; for (NSUInteger i = 0; i < [_selectionRects count]; i++) { @@ -1662,11 +1672,38 @@ - (CGRect)firstRectForRange:(UITextRange*)range { !isLastSelectionRect && _selectionRects[i + 1].position > first; if (startsOnOrBeforeStartOfRange && (endOfTextIsAfterStartOfRange || nextSelectionRectIsAfterStartOfRange)) { - return _selectionRects[i].rect; + // TODO(hellohaunlin): Remove iOS 17 check. The logic should also work for older versions. + if (@available(iOS 17, *)) { + startSelectionRect = _selectionRects[i].rect; + } else { + return _selectionRects[i].rect; + } + } + if (!CGRectIsNull(startSelectionRect)) { + minY = fmin(minY, CGRectGetMinY(_selectionRects[i].rect)); + maxY = fmax(maxY, CGRectGetMaxY(_selectionRects[i].rect)); + BOOL endsOnOrAfterEndOfRange = _selectionRects[i].position >= end - 1; // end is exclusive + BOOL nextSelectionRectIsOnNextLine = + !isLastSelectionRect && + // Selection rects from different langauges in 2 lines may overlap with each other. + // A good approximation is to check if the center of next rect is below the bottom of + // current rect. + // TODO(hellohuanlin): Consider passing the line break info from framework. + CGRectGetMidY(_selectionRects[i + 1].rect) > CGRectGetMaxY(_selectionRects[i].rect); + if (endsOnOrAfterEndOfRange || isLastSelectionRect || nextSelectionRectIsOnNextLine) { + endSelectionRect = _selectionRects[i].rect; + break; + } } } - - return CGRectZero; + if (CGRectIsNull(startSelectionRect) || CGRectIsNull(endSelectionRect)) { + return CGRectZero; + } else { + // fmin/fmax to support both LTR and RTL languages. + CGFloat minX = fmin(CGRectGetMinX(startSelectionRect), CGRectGetMinX(endSelectionRect)); + CGFloat maxX = fmax(CGRectGetMaxX(startSelectionRect), CGRectGetMaxX(endSelectionRect)); + return CGRectMake(minX, minY, maxX - minX, maxY - minY); + } } - (CGRect)caretRectForPosition:(UITextPosition*)position { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 1019e35213723..ba4b250d24edf 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -1438,24 +1438,264 @@ - (void)testUpdateFirstRectForRange { [inputView firstRectForRange:range])); } -- (void)testFirstRectForRangeReturnsCorrectSelectionRect { +- (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineLeftToRight { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; - FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; - CGRect testRect = CGRectMake(100, 100, 100, 100); [inputView setSelectionRects:@[ [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], - [FlutterTextSelectionRect selectionRectWithRect:testRect position:1U], - [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 200, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U], ]]; - XCTAssertTrue(CGRectEqualToRect(testRect, [inputView firstRectForRange:range])); + FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:singleRectRange])); + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } [inputView setTextInputState:@{@"text" : @"COM"}]; FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)]; XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds])); } +- (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineRightToLeft { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U], + ]]; + FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), + [inputView firstRectForRange:singleRectRange])); + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)]; + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } + + [inputView setTextInputState:@{@"text" : @"COM"}]; + FlutterTextRange* rangeOutsideBounds = [FlutterTextRange rangeWithNSRange:NSMakeRange(3, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectZero, [inputView firstRectForRange:rangeOutsideBounds])); +} + +- (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesLeftToRight { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:4U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:5U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:6U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:7U], + ]]; + FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:singleRectRange])); + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectOnMultipleLinesRightToLeft { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 100, 100, 100) position:4U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:5U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:6U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:7U], + ]]; + FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), + [inputView firstRectForRange:singleRectRange])); + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYLeftToRight { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80) + position:1U], // shorter + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120) + position:2U], // taller + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, -10, 300, 120), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 10, 100, 80), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectOnSingleLineWithVaryingMinYAndMaxYRightToLeft { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, -10, 100, 120) + position:1U], // taller + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 10, 100, 80) + position:2U], // shorter + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 3)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, -10, 300, 120), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, -10, 100, 120), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdLeftToRight { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U], + // y=60 exceeds threshold, so treat it as a new line. + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 60, 100, 100) position:4U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsExceedingThresholdRightToLeft { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:3U], + // y=60 exceeds threshold, so treat it as a new line. + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 60, 100, 100) position:4U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 300, 100), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdLeftToRight { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:3U], + // y=40 is within line threshold, so treat it as the same line + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 40, 100, 100) position:4U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 400, 140), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + +- (void)testFirstRectForRangeReturnsCorrectRectWithOverlappingRectsWithinThresholdRightToLeft { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; + + [inputView setSelectionRects:@[ + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(400, 0, 100, 100) position:0U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 0, 100, 100) position:1U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 0, 100, 100) position:2U], + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 0, 100, 100) position:3U], + // y=40 is within line threshold, so treat it as the same line + [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 40, 100, 100) position:4U], + ]]; + + FlutterTextRange* multiRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 4)]; + + if (@available(iOS 17, *)) { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(0, 0, 400, 140), + [inputView firstRectForRange:multiRectRange])); + } else { + XCTAssertTrue(CGRectEqualToRect(CGRectMake(300, 0, 100, 100), + [inputView firstRectForRange:multiRectRange])); + } +} + - (void)testClosestPositionToPoint { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [inputView setTextInputState:@{@"text" : @"COMPOSING"}];