From bf26c499bc74c8472da5a39c35cdf3138c7c74da Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Wed, 9 Aug 2023 15:14:44 -0700 Subject: [PATCH 1/2] [ios]fix highlight on top left corner --- .../Source/FlutterTextInputPlugin.mm | 18 ++- .../Source/FlutterTextInputPluginTest.mm | 106 +++++++++++++++--- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 9072d02f2f785..4abadc04e4e88 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -1606,10 +1606,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; } if (CGRectEqualToRect(_cachedFirstRect, kInvalidFirstRect)) { @@ -1623,9 +1626,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; } @@ -2301,6 +2301,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); } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index c39fa27ae6fbb..95dae023016a7 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -60,6 +60,7 @@ @interface FlutterSecureTextInputView : FlutterTextInputView @interface FlutterTextInputPlugin () @property(nonatomic, assign) FlutterTextInputView* activeView; +@property(nonatomic, readonly) UIView* inputHider; @property(nonatomic, readonly) NSMutableDictionary* autofillContext; @@ -1321,44 +1322,53 @@ - (void)testUpdateFirstRectForRange { @(-6.0), @(3.0), @(9.0), @(1.0) ]; + CGRect kInvalidFirstRectRelative = + [textInputPlugin.viewController.view convertRect:kInvalidFirstRect toView:inputView]; + // 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); @@ -1367,8 +1377,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 { @@ -2190,6 +2200,76 @@ - (void)testInitialActiveViewCantAccessTextInputDelegate { XCTAssertNil(textInputPlugin.activeView.textInputDelegate); } +- (void)testInputHiderIsOffScreenWhenScribbleIsDisabled { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + myInputPlugin.viewController = viewController; + + NSSet* 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 { From a7fdbc0fa8f91399f7a75c5604f93ac25f71f445 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Mon, 14 Aug 2023 20:54:30 -0700 Subject: [PATCH 2/2] fix build issue by moving flutterScreenIfViewLoaded --- .../framework/Source/FlutterTextInputPlugin.h | 12 ++++++++++ .../Source/FlutterTextInputPlugin.mm | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index 5af74609bc5f0..358ba9e6d5e3e 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -164,4 +164,16 @@ FLUTTER_DARWIN_EXPORT - (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin NS_DESIGNATED_INITIALIZER; @end + +@interface UIViewController (FlutterScreenAndSceneIfLoaded) + +/// Returns a UIWindowScene if the UIViewController's view is loaded, and nil otherwise. +- (UIWindowScene*)flutterWindowSceneIfViewLoaded API_AVAILABLE(ios(13.0)); + +/// Before iOS 13, returns the main screen; After iOS 13, returns the screen the UIViewController is +/// attached to if its view is loaded, and nil otherwise. +- (UIScreen*)flutterScreenIfViewLoaded; + +@end + #endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 4abadc04e4e88..7774d70ab1286 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -2762,3 +2762,26 @@ - (BOOL)handlePress:(nonnull FlutterUIPressProxy*)press API_AVAILABLE(ios(13.4)) return NO; } @end + +@implementation UIViewController (FlutterScreenAndSceneIfLoaded) + +- (UIWindowScene*)flutterWindowSceneIfViewLoaded { + if (self.viewIfLoaded == nil) { + FML_LOG(WARNING) << "Trying to access the window scene before the view is loaded."; + return nil; + } + return self.viewIfLoaded.window.windowScene; +} + +- (UIScreen*)flutterScreenIfViewLoaded { + if (@available(iOS 13.0, *)) { + if (self.viewIfLoaded == nil) { + FML_LOG(WARNING) << "Trying to access the screen before the view is loaded."; + return nil; + } + return [self flutterWindowSceneIfViewLoaded].screen; + } + return UIScreen.mainScreen; +} + +@end