Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

[ios][iOS 17] fix auto-correction highlight on top left corner #44604

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1625,10 +1625,13 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
NSUInteger start = ((FlutterTextPosition*)range.start).index;
NSUInteger end = ((FlutterTextPosition*)range.end).index;
if (_markedTextRange != nil) {
UIView* hostView = _textInputPlugin.hostView;
NSAssert(hostView == nil || [self isDescendantOfView:hostView], @"%@ is not a descendant of %@",
self, hostView);
// The candidates view can't be shown if the framework has not sent the
// first caret rect.
if (CGRectEqualToRect(kInvalidFirstRect, _markedRect)) {
return kInvalidFirstRect;
return hostView ? [hostView convertRect:kInvalidFirstRect toView:self] : kInvalidFirstRect;
Copy link
Contributor Author

@hellohuanlin hellohuanlin Aug 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LongCatIsLooong I'm a bit iffy on this one and could use another pair of eyes:

My understanding of your previous PR is that this should return rects in FlutterTextInputView's coord space - not only valid rects, but also this dummy invalid rect (because otherwise an invalid rect could very well become a valid rect with some transforms of FlutterTextInputView).

Let me know if you wanna discuss offline.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think kInvalidFirstRect is probably specified in the window's coordinate system so it needs to be transformed. The input view used to be placed at the top left corner of the window iirc.

}

if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) {
Expand All @@ -1642,9 +1645,6 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
_cachedFirstRect = [self localRectFromFrameworkTransform:rect];
}

UIView* hostView = _textInputPlugin.hostView;
NSAssert(hostView == nil || [self isDescendantOfView:hostView], @"%@ is not a descendant of %@",
self, hostView);
return hostView ? [hostView convertRect:_cachedFirstRect toView:self] : _cachedFirstRect;
}

Expand Down Expand Up @@ -2461,6 +2461,16 @@ - (void)setEditableSizeAndTransform:(NSDictionary*)dictionary {
_activeView.frame =
CGRectMake(0, 0, [dictionary[@"width"] intValue], [dictionary[@"height"] intValue]);
_activeView.tintColor = [UIColor clearColor];
} else {
// View must be loaded at this point.
UIScreen* screen = _viewController.flutterScreenIfViewLoaded;

// Position FlutterTextInputView outside of the screen (if scribble is disabled).
// This is to fix a bug where native auto-correction highlight is displayed on
// top left corner of the screen (See: https://github.com/flutter/flutter/issues/131695)
// and a bug where the native auto-correction suggestion menu displayed (See:
// https://github.com/flutter/flutter/issues/130818).
_inputHider.frame = CGRectMake(0, -screen.bounds.size.height, 0, 0);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ @interface FlutterSecureTextInputView : FlutterTextInputView

@interface FlutterTextInputPlugin ()
@property(nonatomic, assign) FlutterTextInputView* activeView;
@property(nonatomic, readonly) UIView* inputHider;
@property(nonatomic, readonly) UIView* keyboardViewContainer;
@property(nonatomic, readonly) UIView* keyboardView;
@property(nonatomic, assign) UIView* cachedFirstResponder;
Expand Down Expand Up @@ -1426,44 +1427,53 @@ - (void)testUpdateFirstRectForRange {
@(-6.0), @(3.0), @(9.0), @(1.0)
];

CGRect kInvalidFirstRectRelative =
[textInputPlugin.viewController.view convertRect:kInvalidFirstRect toView:inputView];
Copy link
Contributor Author

@hellohuanlin hellohuanlin Aug 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wanna highlight the changes to this test case. Let me know if it makes sense @LongCatIsLooong


// Invalid since we don't have the transform or the rect.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));

[inputView setEditableTransform:yOffsetMatrix];
// Invalid since we don't have the rect.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));

// Valid rect and transform.
CGRect testRect = CGRectMake(0, 0, 100, 100);
[inputView setMarkedRect:testRect];

CGRect finalRect = CGRectOffset(testRect, 0, 200);
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
CGRect finalRectRelative = [textInputPlugin.viewController.view convertRect:finalRect
toView:inputView];
XCTAssertTrue(CGRectEqualToRect(finalRectRelative, [inputView firstRectForRange:range]));
// Idempotent.
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(finalRectRelative, [inputView firstRectForRange:range]));

// Use an invalid matrix:
[inputView setEditableTransform:zeroMatrix];
// Invalid matrix is invalid.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));

// Revert the invalid matrix change.
[inputView setEditableTransform:yOffsetMatrix];
[inputView setMarkedRect:testRect];
XCTAssertTrue(CGRectEqualToRect(finalRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(finalRectRelative, [inputView firstRectForRange:range]));

// Use an invalid rect:
[inputView setMarkedRect:kInvalidFirstRect];
// Invalid marked rect is invalid.
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));
XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRectRelative, [inputView firstRectForRange:range]));

// Use a 3d affine transform that does 3d-scaling, z-index rotating and 3d translation.
[inputView setEditableTransform:affineMatrix];
[inputView setMarkedRect:testRect];
XCTAssertTrue(
CGRectEqualToRect(CGRectMake(-306, 3, 300, 300), [inputView firstRectForRange:range]));

CGRect relativeRect =
[textInputPlugin.viewController.view convertRect:CGRectMake(-306, 3, 300, 300)
toView:inputView];

XCTAssertTrue(CGRectEqualToRect(relativeRect, [inputView firstRectForRange:range]));

NSAssert(inputView.superview, @"inputView is not in the view hierarchy!");
const CGPoint offset = CGPointMake(113, 119);
Expand All @@ -1472,8 +1482,8 @@ - (void)testUpdateFirstRectForRange {
inputView.frame = currentFrame;
// Moving the input view within the FlutterView shouldn't affect the coordinates,
// since the framework sends us global coordinates.
XCTAssertTrue(CGRectEqualToRect(CGRectMake(-306 - 113, 3 - 119, 300, 300),
[inputView firstRectForRange:range]));
CGRect target = CGRectOffset(relativeRect, -113, -119);
XCTAssertTrue(CGRectEqualToRect(target, [inputView firstRectForRange:range]));
}

- (void)testFirstRectForRangeReturnsCorrectSelectionRect {
Expand Down Expand Up @@ -2295,6 +2305,76 @@ - (void)testInitialActiveViewCantAccessTextInputDelegate {
XCTAssertNil(textInputPlugin.activeView.textInputDelegate);
}

- (void)testInputHiderIsOffScreenWhenScribbleIsDisabled {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];
myInputPlugin.viewController = viewController;

NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
UIScene* scene = scenes.anyObject;
XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
UIWindowScene* windowScene = (UIWindowScene*)scene;
XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
UIWindow* window = windowScene.windows[0];
[window addSubview:viewController.view];
[viewController loadView];
UIScreen* screen = viewController.flutterScreenIfViewLoaded;
XCTAssertNotNil(screen, @"Screen must be present at this point");

FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];

FlutterTextInputView* mockInputView = OCMPartialMock(myInputPlugin.activeView);
OCMStub([mockInputView isScribbleAvailable]).andReturn(NO);

// yOffset = 200.
NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];

FlutterMethodCall* setPlatformViewClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
arguments:@{@"transform" : yOffsetMatrix}];
[myInputPlugin handleMethodCall:setPlatformViewClientCall
result:^(id _Nullable result){
}];

CGRect offScreenRect = CGRectMake(0, -screen.bounds.size.height, 0, 0);
XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, offScreenRect),
@"The input hider should stay offScreen if scribble is disabled.");
}

- (void)testInputHiderIsOnScreenWhenScribbleIsEnabled {
FlutterTextInputPlugin* myInputPlugin =
[[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])];

FlutterMethodCall* setClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient"
arguments:@[ @(123), self.mutableTemplateCopy ]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];

FlutterTextInputView* mockInputView = OCMPartialMock(myInputPlugin.activeView);
OCMStub([mockInputView isScribbleAvailable]).andReturn(YES);

// yOffset = 200.
NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ];

FlutterMethodCall* setPlatformViewClientCall =
[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditableSizeAndTransform"
arguments:@{@"transform" : yOffsetMatrix}];
[myInputPlugin handleMethodCall:setPlatformViewClientCall
result:^(id _Nullable result){
}];

XCTAssertEqual(myInputPlugin.inputHider.frame.origin.y, 200,
@"The input hider should be brought on screen if scribble is enabled");
}

#pragma mark - Accessibility - Tests

- (void)testUITextInputAccessibilityNotHiddenWhenShowed {
Expand Down