| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "flutter/shell/platform/darwin/common/framework/Source/FlutterBinaryMessengerRelay.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Test.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" |
| |
| #import <OCMock/OCMock.h> |
| #import <XCTest/XCTest.h> |
| |
| #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h" |
| #import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h" |
| |
| FLUTTER_ASSERT_ARC |
| |
| @interface FlutterEngine () |
| - (nonnull FlutterTextInputPlugin*)textInputPlugin; |
| @end |
| |
| @interface FlutterTextInputView () |
| @property(nonatomic, copy) NSString* autofillId; |
| - (void)setEditableTransform:(NSArray*)matrix; |
| - (void)setTextInputClient:(int)client; |
| - (void)setTextInputState:(NSDictionary*)state; |
| - (void)setMarkedRect:(CGRect)markedRect; |
| - (void)updateEditingState; |
| - (BOOL)isVisibleToAutofill; |
| - (id<FlutterTextInputDelegate>)textInputDelegate; |
| - (void)configureWithDictionary:(NSDictionary*)configuration; |
| @end |
| |
| @interface FlutterTextInputViewSpy : FlutterTextInputView |
| @property(nonatomic, assign) UIAccessibilityNotifications receivedNotification; |
| @property(nonatomic, assign) id receivedNotificationTarget; |
| @property(nonatomic, assign) BOOL isAccessibilityFocused; |
| |
| - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target; |
| |
| @end |
| |
| @implementation FlutterTextInputViewSpy { |
| } |
| |
| - (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target { |
| self.receivedNotification = notification; |
| self.receivedNotificationTarget = target; |
| } |
| |
| - (BOOL)accessibilityElementIsFocused { |
| return _isAccessibilityFocused; |
| } |
| |
| @end |
| |
| @interface FlutterSecureTextInputView : FlutterTextInputView |
| @property(nonatomic, strong) UITextField* textField; |
| @end |
| |
| @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; |
| @property(nonatomic, readonly) CGRect keyboardRect; |
| @property(nonatomic, readonly) |
| NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext; |
| |
| - (void)cleanUpViewHierarchy:(BOOL)includeActiveView |
| clearText:(BOOL)clearText |
| delayRemoval:(BOOL)delayRemoval; |
| - (NSArray<UIView*>*)textInputViews; |
| - (UIView*)hostView; |
| - (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView; |
| - (void)startLiveTextInput; |
| - (void)showKeyboardAndRemoveScreenshot; |
| |
| @end |
| |
| @interface FlutterTextInputPluginTest : XCTestCase |
| @end |
| |
| @implementation FlutterTextInputPluginTest { |
| NSDictionary* _template; |
| NSDictionary* _passwordTemplate; |
| id engine; |
| FlutterTextInputPlugin* textInputPlugin; |
| |
| FlutterViewController* viewController; |
| } |
| |
| - (void)setUp { |
| [super setUp]; |
| engine = OCMClassMock([FlutterEngine class]); |
| |
| textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; |
| |
| viewController = [[FlutterViewController alloc] init]; |
| textInputPlugin.viewController = viewController; |
| |
| // Clear pasteboard between tests. |
| UIPasteboard.generalPasteboard.items = @[]; |
| } |
| |
| - (void)tearDown { |
| textInputPlugin = nil; |
| engine = nil; |
| [textInputPlugin.autofillContext removeAllObjects]; |
| [textInputPlugin cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO]; |
| [[[[textInputPlugin textInputView] superview] subviews] |
| makeObjectsPerformSelector:@selector(removeFromSuperview)]; |
| viewController = nil; |
| [super tearDown]; |
| } |
| |
| - (void)setClientId:(int)clientId configuration:(NSDictionary*)config { |
| FlutterMethodCall* setClientCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" |
| arguments:@[ [NSNumber numberWithInt:clientId], config ]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (void)setTextInputShow { |
| FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show" |
| arguments:@[]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (void)setTextInputHide { |
| FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" |
| arguments:@[]]; |
| [textInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| - (void)flushScheduledAsyncBlocks { |
| __block bool done = false; |
| XCTestExpectation* expectation = |
| [[XCTestExpectation alloc] initWithDescription:@"Testing on main queue"]; |
| dispatch_async(dispatch_get_main_queue(), ^{ |
| done = true; |
| }); |
| dispatch_async(dispatch_get_main_queue(), ^{ |
| XCTAssertTrue(done); |
| [expectation fulfill]; |
| }); |
| [self waitForExpectations:@[ expectation ] timeout:10]; |
| } |
| |
| - (NSMutableDictionary*)mutableTemplateCopy { |
| if (!_template) { |
| _template = @{ |
| @"inputType" : @{@"name" : @"TextInuptType.text"}, |
| @"keyboardAppearance" : @"Brightness.light", |
| @"obscureText" : @NO, |
| @"inputAction" : @"TextInputAction.unspecified", |
| @"smartDashesType" : @"0", |
| @"smartQuotesType" : @"0", |
| @"autocorrect" : @YES, |
| @"enableInteractiveSelection" : @YES, |
| }; |
| } |
| |
| return [_template mutableCopy]; |
| } |
| |
| - (NSArray<FlutterTextInputView*>*)installedInputViews { |
| return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews |
| filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@", |
| [FlutterTextInputView class]]]; |
| } |
| |
| - (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokenizer |
| atIndex:(NSInteger)index { |
| UITextRange* range = |
| [tokenizer rangeEnclosingPosition:[FlutterTextPosition positionWithIndex:index] |
| withGranularity:UITextGranularityLine |
| inDirection:UITextLayoutDirectionRight]; |
| XCTAssertTrue([range isKindOfClass:[FlutterTextRange class]]); |
| return (FlutterTextRange*)range; |
| } |
| |
| - (void)updateConfig:(NSDictionary*)config { |
| FlutterMethodCall* updateConfigCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.updateConfig" arguments:config]; |
| [textInputPlugin handleMethodCall:updateConfigCall |
| result:^(id _Nullable result){ |
| }]; |
| } |
| |
| #pragma mark - Tests |
| |
| - (void)testWillNotCrashWhenViewControllerIsNil { |
| FlutterEngine* flutterEngine = [[FlutterEngine alloc] init]; |
| FlutterTextInputPlugin* inputPlugin = |
| [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine]; |
| XCTAssertNil(inputPlugin.viewController); |
| FlutterMethodCall* methodCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show" |
| arguments:nil]; |
| XCTestExpectation* expectation = [[XCTestExpectation alloc] initWithDescription:@"result called"]; |
| |
| [inputPlugin handleMethodCall:methodCall |
| result:^(id _Nullable result) { |
| XCTAssertNil(result); |
| [expectation fulfill]; |
| }]; |
| XCTAssertNil(inputPlugin.activeView); |
| [self waitForExpectations:@[ expectation ] timeout:1.0]; |
| } |
| |
| - (void)testInvokeStartLiveTextInput { |
| FlutterMethodCall* methodCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.startLiveTextInput" arguments:nil]; |
| FlutterTextInputPlugin* mockPlugin = OCMPartialMock(textInputPlugin); |
| [mockPlugin handleMethodCall:methodCall |
| result:^(id _Nullable result){ |
| }]; |
| OCMVerify([mockPlugin startLiveTextInput]); |
| } |
| |
| - (void)testNoDanglingEnginePointer { |
| __weak FlutterTextInputPlugin* weakFlutterTextInputPlugin; |
| FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; |
| __weak FlutterEngine* weakFlutterEngine; |
| |
| FlutterTextInputView* currentView; |
| |
| // The engine instance will be deallocated after the autorelease pool is drained. |
| @autoreleasepool { |
| FlutterEngine* flutterEngine = OCMClassMock([FlutterEngine class]); |
| weakFlutterEngine = flutterEngine; |
| NSAssert(weakFlutterEngine, @"flutter engine must not be nil"); |
| FlutterTextInputPlugin* flutterTextInputPlugin = [[FlutterTextInputPlugin alloc] |
| initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine]; |
| weakFlutterTextInputPlugin = flutterTextInputPlugin; |
| flutterTextInputPlugin.viewController = flutterViewController; |
| |
| // Set client so the text input plugin has an active view. |
| NSDictionary* config = self.mutableTemplateCopy; |
| FlutterMethodCall* setClientCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" |
| arguments:@[ [NSNumber numberWithInt:123], config ]]; |
| [flutterTextInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| currentView = flutterTextInputPlugin.activeView; |
| } |
| |
| NSAssert(!weakFlutterEngine, @"flutter engine must be nil"); |
| NSAssert(currentView, @"current view must not be nil"); |
| |
| XCTAssertNil(weakFlutterTextInputPlugin); |
| // Verify that the view can no longer access the deallocated engine/text input plugin |
| // instance. |
| XCTAssertNil(currentView.textInputDelegate); |
| } |
| |
| - (void)testSecureInput { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // There are no autofill and the mock framework requested a secure entry. The first and only |
| // inserted FlutterTextInputView should be a secure text entry one. |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| // Verify secureTextEntry is set to the correct value. |
| XCTAssertTrue(inputView.secureTextEntry); |
| |
| // Verify keyboardType is set to the default value. |
| XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeDefault); |
| |
| // We should have only ever created one FlutterTextInputView. |
| XCTAssertEqual(inputFields.count, 1ul); |
| |
| // The one FlutterTextInputView we inserted into the view hierarchy should be the text input |
| // plugin's active text input view. |
| XCTAssertEqual(inputView, textInputPlugin.textInputView); |
| |
| // Despite not given an id in configuration, inputView has |
| // an autofill id. |
| XCTAssert(inputView.autofillId.length > 0); |
| } |
| |
| - (void)testKeyboardType { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| // Verify keyboardType is set to the value specified in config. |
| XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeURL); |
| } |
| |
| - (void)testVisiblePasswordUseAlphanumeric { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@{@"name" : @"TextInputType.visiblePassword"} forKey:@"inputType"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| // Verify keyboardType is set to the value specified in config. |
| XCTAssertEqual(inputView.keyboardType, UIKeyboardTypeASCIICapable); |
| } |
| |
| - (void)testSettingKeyboardTypeNoneDisablesSystemKeyboard { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@{@"name" : @"TextInputType.none"} forKey:@"inputType"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Verify the view's inputViewController is not nil; |
| XCTAssertNotNil(textInputPlugin.activeView.inputViewController); |
| |
| [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"]; |
| [self setClientId:124 configuration:config]; |
| XCTAssertNotNil(textInputPlugin.activeView); |
| XCTAssertNil(textInputPlugin.activeView.inputViewController); |
| } |
| |
| - (void)testAutocorrectionPromptRectAppearsBeforeIOS17AndDoesNotAppearAfterIOS17 { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| |
| if (@available(iOS 17.0, *)) { |
| // Auto-correction prompt is disabled in iOS 17+. |
| OCMVerify(never(), [engine flutterTextInputView:inputView |
| showAutocorrectionPromptRectForStart:0 |
| end:1 |
| withClient:0]); |
| } else { |
| OCMVerify([engine flutterTextInputView:inputView |
| showAutocorrectionPromptRectForStart:0 |
| end:1 |
| withClient:0]); |
| } |
| } |
| |
| - (void)testIgnoresSelectionChangeIfSelectionIsDisabled { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"Some initial text"]; |
| XCTAssertEqual(updateCount, 0); |
| |
| FlutterTextRange* textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| [inputView setSelectedTextRange:textRange]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Disable the interactive selection. |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@(NO) forKey:@"enableInteractiveSelection"]; |
| [config setValue:@(NO) forKey:@"obscureText"]; |
| [config setValue:@(NO) forKey:@"enableDeltaModel"]; |
| [inputView configureWithDictionary:config]; |
| |
| textRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(2, 3)]; |
| [inputView setSelectedTextRange:textRange]; |
| // The update count does not change. |
| XCTAssertEqual(updateCount, 1); |
| } |
| |
| - (void)testAutocorrectionPromptRectDoesNotAppearDuringScribble { |
| // Auto-correction prompt is disabled in iOS 17+. |
| if (@available(iOS 17.0, *)) { |
| return; |
| } |
| |
| if (@available(iOS 14.0, *)) { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| __block int callCount = 0; |
| OCMStub([engine flutterTextInputView:inputView |
| showAutocorrectionPromptRectForStart:0 |
| end:1 |
| withClient:0]) |
| .andDo(^(NSInvocation* invocation) { |
| callCount++; |
| }); |
| |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart fires in response to firstRectForRange |
| XCTAssertEqual(callCount, 1); |
| |
| UIScribbleInteraction* scribbleInteraction = |
| [[UIScribbleInteraction alloc] initWithDelegate:inputView]; |
| |
| [inputView scribbleInteractionWillBeginWriting:scribbleInteraction]; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart does not fire in response to setMarkedText during a |
| // scribble interaction.firstRectForRange |
| XCTAssertEqual(callCount, 1); |
| |
| [inputView scribbleInteractionDidFinishWriting:scribbleInteraction]; |
| [inputView resetScribbleInteractionStatusIfEnding]; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart fires in response to firstRectForRange. |
| XCTAssertEqual(callCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange during a |
| // scribble-initiated focus. |
| XCTAssertEqual(callCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart does not fire in response to firstRectForRange after a |
| // scribble-initiated focus. |
| XCTAssertEqual(callCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; |
| [inputView firstRectForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]]; |
| // showAutocorrectionPromptRectForStart fires in response to firstRectForRange. |
| XCTAssertEqual(callCount, 3); |
| } |
| } |
| |
| - (void)testInputHiderOverlapWithTextWhenScribbleIsDisabledAfterIOS17AndDoesNotOverlapBeforeIOS17 { |
| 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(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){ |
| }]; |
| |
| if (@available(iOS 17, *)) { |
| XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectMake(0, 200, 0, 0)), |
| @"The input hider should overlap with the text on and after iOS 17"); |
| |
| } else { |
| XCTAssert(CGRectEqualToRect(myInputPlugin.inputHider.frame, CGRectZero), |
| @"The input hider should be on the origin of screen on and before iOS 16."); |
| } |
| } |
| |
| - (void)testTextRangeFromPositionMatchesUITextViewBehavior { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| FlutterTextPosition* fromPosition = [FlutterTextPosition positionWithIndex:2]; |
| FlutterTextPosition* toPosition = [FlutterTextPosition positionWithIndex:0]; |
| |
| FlutterTextRange* flutterRange = (FlutterTextRange*)[inputView textRangeFromPosition:fromPosition |
| toPosition:toPosition]; |
| NSRange range = flutterRange.range; |
| |
| XCTAssertEqual(range.location, 0ul); |
| XCTAssertEqual(range.length, 2ul); |
| } |
| |
| - (void)testTextInRange { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@{@"name" : @"TextInputType.url"} forKey:@"inputType"]; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| [inputView insertText:@"test"]; |
| |
| UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 20)]; |
| NSString* substring = [inputView textInRange:range]; |
| XCTAssertEqual(substring.length, 4ul); |
| |
| range = [FlutterTextRange rangeWithNSRange:NSMakeRange(10, 20)]; |
| substring = [inputView textInRange:range]; |
| XCTAssertEqual(substring.length, 0ul); |
| } |
| |
| - (void)testStandardEditActions { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| [inputView insertText:@"aaaa"]; |
| [inputView selectAll:nil]; |
| [inputView cut:nil]; |
| [inputView insertText:@"bbbb"]; |
| XCTAssertTrue([inputView canPerformAction:@selector(paste:) withSender:nil]); |
| [inputView paste:nil]; |
| [inputView selectAll:nil]; |
| [inputView copy:nil]; |
| [inputView paste:nil]; |
| [inputView selectAll:nil]; |
| [inputView delete:nil]; |
| [inputView paste:nil]; |
| [inputView paste:nil]; |
| |
| UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 30)]; |
| NSString* substring = [inputView textInRange:range]; |
| XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa"); |
| } |
| |
| - (void)testDeletingBackward { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| [inputView insertText:@"ឹ😀 text 🥰👨👩👧👦🇺🇳ดี "]; |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| |
| // Thai vowel is removed. |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨👩👧👦🇺🇳ด"); |
| [inputView deleteBackward]; |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨👩👧👦🇺🇳"); |
| [inputView deleteBackward]; |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰👨👩👧👦"); |
| [inputView deleteBackward]; |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀 text 🥰"); |
| [inputView deleteBackward]; |
| |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀 text "); |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| [inputView deleteBackward]; |
| |
| XCTAssertEqualObjects(inputView.text, @"ឹ😀"); |
| [inputView deleteBackward]; |
| XCTAssertEqualObjects(inputView.text, @"ឹ"); |
| [inputView deleteBackward]; |
| XCTAssertEqualObjects(inputView.text, @""); |
| } |
| |
| // This tests the workaround to fix an iOS 16 bug |
| // See: https://github.com/flutter/flutter/issues/111494 |
| - (void)testSystemOnlyAddingPartialComposedCharacter { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| [inputView insertText:@"👨👩👧👦"]; |
| [inputView deleteBackward]; |
| |
| // Insert the first unichar in the emoji. |
| [inputView insertText:[@"👨👩👧👦" substringWithRange:NSMakeRange(0, 1)]]; |
| [inputView insertText:@"아"]; |
| |
| XCTAssertEqualObjects(inputView.text, @"👨👩👧👦아"); |
| |
| // Deleting 아. |
| [inputView deleteBackward]; |
| // 👨👩👧👦 should be the current string. |
| |
| [inputView insertText:@"😀"]; |
| [inputView deleteBackward]; |
| // Insert the first unichar in the emoji. |
| [inputView insertText:[@"😀" substringWithRange:NSMakeRange(0, 1)]]; |
| [inputView insertText:@"아"]; |
| XCTAssertEqualObjects(inputView.text, @"👨👩👧👦😀아"); |
| |
| // Deleting 아. |
| [inputView deleteBackward]; |
| // 👨👩👧👦😀 should be the current string. |
| |
| [inputView deleteBackward]; |
| // Insert the first unichar in the emoji. |
| [inputView insertText:[@"😀" substringWithRange:NSMakeRange(0, 1)]]; |
| [inputView insertText:@"아"]; |
| |
| XCTAssertEqualObjects(inputView.text, @"👨👩👧👦😀아"); |
| } |
| |
| - (void)testCachedComposedCharacterClearedAtKeyboardInteraction { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| [inputView insertText:@"👨👩👧👦"]; |
| [inputView deleteBackward]; |
| [inputView shouldChangeTextInRange:OCMClassMock([UITextRange class]) replacementText:@""]; |
| |
| // Insert the first unichar in the emoji. |
| NSString* brokenEmoji = [@"👨👩👧👦" substringWithRange:NSMakeRange(0, 1)]; |
| [inputView insertText:brokenEmoji]; |
| [inputView insertText:@"아"]; |
| |
| NSString* finalText = [NSString stringWithFormat:@"%@아", brokenEmoji]; |
| XCTAssertEqualObjects(inputView.text, finalText); |
| } |
| |
| - (void)testPastingNonTextDisallowed { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| UIPasteboard.generalPasteboard.color = UIColor.redColor; |
| XCTAssertNil(UIPasteboard.generalPasteboard.string); |
| XCTAssertFalse([inputView canPerformAction:@selector(paste:) withSender:nil]); |
| [inputView paste:nil]; |
| |
| XCTAssertEqualObjects(inputView.text, @""); |
| } |
| |
| - (void)testNoZombies { |
| // Regression test for https://github.com/flutter/flutter/issues/62501. |
| FlutterSecureTextInputView* passwordView = |
| [[FlutterSecureTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| @autoreleasepool { |
| // Initialize the lazy textField. |
| [passwordView.textField description]; |
| } |
| XCTAssert([[passwordView.textField description] containsString:@"TextField"]); |
| } |
| |
| - (void)testInputViewCrash { |
| FlutterTextInputView* activeView = nil; |
| @autoreleasepool { |
| FlutterEngine* flutterEngine = [[FlutterEngine alloc] init]; |
| FlutterTextInputPlugin* inputPlugin = [[FlutterTextInputPlugin alloc] |
| initWithDelegate:(id<FlutterTextInputDelegate>)flutterEngine]; |
| activeView = inputPlugin.activeView; |
| } |
| [activeView updateEditingState]; |
| } |
| |
| - (void)testDoNotReuseInputViews { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| FlutterTextInputView* currentView = textInputPlugin.activeView; |
| [self setClientId:456 configuration:config]; |
| |
| XCTAssertNotNil(currentView); |
| XCTAssertNotNil(textInputPlugin.activeView); |
| XCTAssertNotEqual(currentView, textInputPlugin.activeView); |
| } |
| |
| - (void)ensureOnlyActiveViewCanBecomeFirstResponder { |
| for (FlutterTextInputView* inputView in self.installedInputViews) { |
| XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView); |
| } |
| } |
| |
| - (void)testPropagatePressEventsToViewController { |
| FlutterViewController* mockViewController = OCMPartialMock(viewController); |
| OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]); |
| OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]); |
| |
| textInputPlugin.viewController = mockViewController; |
| |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| FlutterTextInputView* currentView = textInputPlugin.activeView; |
| [self setTextInputShow]; |
| |
| [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] |
| withEvent:OCMClassMock([UIPressesEvent class])]; |
| |
| OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| |
| [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] |
| withEvent:OCMClassMock([UIPressesEvent class])]; |
| |
| OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| } |
| |
| - (void)testPropagatePressEventsToViewController2 { |
| FlutterViewController* mockViewController = OCMPartialMock(viewController); |
| OCMStub([mockViewController pressesBegan:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]); |
| OCMStub([mockViewController pressesEnded:[OCMArg isNotNil] withEvent:[OCMArg isNotNil]]); |
| |
| textInputPlugin.viewController = mockViewController; |
| |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| [self setTextInputShow]; |
| FlutterTextInputView* currentView = textInputPlugin.activeView; |
| |
| [currentView pressesBegan:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] |
| withEvent:OCMClassMock([UIPressesEvent class])]; |
| |
| OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| OCMVerify(times(0), [mockViewController pressesEnded:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| |
| // Switch focus to a different view. |
| [self setClientId:321 configuration:config]; |
| [self setTextInputShow]; |
| NSAssert(textInputPlugin.activeView, @"active view must not be nil"); |
| NSAssert(textInputPlugin.activeView != currentView, @"active view must change"); |
| currentView = textInputPlugin.activeView; |
| [currentView pressesEnded:[NSSet setWithObjects:OCMClassMock([UIPress class]), nil] |
| withEvent:OCMClassMock([UIPressesEvent class])]; |
| |
| OCMVerify(times(1), [mockViewController pressesBegan:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| OCMVerify(times(1), [mockViewController pressesEnded:[OCMArg isNotNil] |
| withEvent:[OCMArg isNotNil]]); |
| } |
| |
| - (void)testUpdateSecureTextEntry { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| [self setClientId:123 configuration:config]; |
| |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = OCMPartialMock(inputFields[0]); |
| |
| __block int callCount = 0; |
| OCMStub([inputView reloadInputViews]).andDo(^(NSInvocation* invocation) { |
| callCount++; |
| }); |
| |
| XCTAssertTrue(inputView.isSecureTextEntry); |
| |
| config = self.mutableTemplateCopy; |
| [config setValue:@"NO" forKey:@"obscureText"]; |
| [self updateConfig:config]; |
| |
| XCTAssertEqual(callCount, 1); |
| XCTAssertFalse(inputView.isSecureTextEntry); |
| } |
| |
| - (void)testInputActionContinueAction { |
| id mockBinaryMessenger = OCMClassMock([FlutterBinaryMessengerRelay class]); |
| FlutterEngine* testEngine = [[FlutterEngine alloc] init]; |
| [testEngine setBinaryMessenger:mockBinaryMessenger]; |
| [testEngine runWithEntrypoint:FlutterDefaultDartEntrypoint initialRoute:@"test"]; |
| |
| FlutterTextInputPlugin* inputPlugin = |
| [[FlutterTextInputPlugin alloc] initWithDelegate:(id<FlutterTextInputDelegate>)testEngine]; |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:inputPlugin]; |
| |
| [testEngine flutterTextInputView:inputView |
| performAction:FlutterTextInputActionContinue |
| withClient:123]; |
| |
| FlutterMethodCall* methodCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInputClient.performAction" |
| arguments:@[ @(123), @"TextInputAction.continueAction" ]]; |
| NSData* encodedMethodCall = [[FlutterJSONMethodCodec sharedInstance] encodeMethodCall:methodCall]; |
| OCMVerify([mockBinaryMessenger sendOnChannel:@"flutter/textinput" message:encodedMethodCall]); |
| } |
| |
| - (void)testDisablingAutocorrectDisablesSpellChecking { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| // Disable the interactive selection. |
| NSDictionary* config = self.mutableTemplateCopy; |
| [inputView configureWithDictionary:config]; |
| |
| XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeDefault); |
| XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeDefault); |
| |
| [config setValue:@(NO) forKey:@"autocorrect"]; |
| [inputView configureWithDictionary:config]; |
| |
| XCTAssertEqual(inputView.autocorrectionType, UITextAutocorrectionTypeNo); |
| XCTAssertEqual(inputView.spellCheckingType, UITextSpellCheckingTypeNo); |
| } |
| |
| - (void)testReplaceTestLocalAdjustSelectionAndMarkedTextRange { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setMarkedText:@"test text" selectedRange:NSMakeRange(0, 5)]; |
| NSRange selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range; |
| const NSRange markedTextRange = ((FlutterTextRange*)inputView.markedTextRange).range; |
| XCTAssertEqual(selectedTextRange.location, 0ul); |
| XCTAssertEqual(selectedTextRange.length, 5ul); |
| XCTAssertEqual(markedTextRange.location, 0ul); |
| XCTAssertEqual(markedTextRange.length, 9ul); |
| |
| // Replaces space with space. |
| [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(4, 1)] withText:@" "]; |
| selectedTextRange = ((FlutterTextRange*)inputView.selectedTextRange).range; |
| |
| XCTAssertEqual(selectedTextRange.location, 5ul); |
| XCTAssertEqual(selectedTextRange.length, 0ul); |
| XCTAssertEqual(inputView.markedTextRange, nil); |
| } |
| |
| - (void)testFlutterTextInputViewOnlyRespondsToInsertionPointColorBelowIOS17 { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| BOOL respondsToInsertionPointColor = |
| [inputView respondsToSelector:@selector(insertionPointColor)]; |
| if (@available(iOS 17, *)) { |
| XCTAssertFalse(respondsToInsertionPointColor); |
| } else { |
| XCTAssertTrue(respondsToInsertionPointColor); |
| } |
| } |
| |
| #pragma mark - TextEditingDelta tests |
| - (void)testTextEditingDeltasAreGeneratedOnTextInput { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| |
| [inputView insertText:@"text to insert"]; |
| OCMExpect( |
| [engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@""]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"text to insert"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 0); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| XCTAssertEqual(updateCount, 0); |
| |
| [self flushScheduledAsyncBlocks]; |
| |
| // Update the framework exactly once. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView deleteBackward]; |
| OCMExpect([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"text to insert"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@""]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] |
| intValue] == 13) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] |
| intValue] == 14); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| OCMExpect([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"text to inser"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@""]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] |
| intValue] == -1) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] |
| intValue] == -1); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 3); |
| |
| [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] |
| withText:@"replace text"]; |
| OCMExpect( |
| [engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"text to inser"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"replace text"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 0) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 1); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 4); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| OCMExpect([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"replace textext to inser"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"marked text"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] |
| intValue] == 12) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] |
| intValue] == 12); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 5); |
| |
| [inputView unmarkText]; |
| OCMExpect([engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"replace textmarked textext to inser"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@""]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == |
| -1) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == |
| -1); |
| }]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| [self flushScheduledAsyncBlocks]; |
| |
| XCTAssertEqual(updateCount, 6); |
| OCMVerifyAll(engine); |
| } |
| |
| - (void)testTextEditingDeltasAreBatchedAndForwardedToFramework { |
| // Setup |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| // Expected call. |
| OCMExpect([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| NSArray* deltas = state[@"deltas"]; |
| NSDictionary* firstDelta = deltas[0]; |
| NSDictionary* secondDelta = deltas[1]; |
| NSDictionary* thirdDelta = deltas[2]; |
| return [firstDelta[@"oldText"] isEqualToString:@""] && |
| [firstDelta[@"deltaText"] isEqualToString:@"-"] && |
| [firstDelta[@"deltaStart"] intValue] == 0 && |
| [firstDelta[@"deltaEnd"] intValue] == 0 && |
| [secondDelta[@"oldText"] isEqualToString:@"-"] && |
| [secondDelta[@"deltaText"] isEqualToString:@""] && |
| [secondDelta[@"deltaStart"] intValue] == 0 && |
| [secondDelta[@"deltaEnd"] intValue] == 1 && |
| [thirdDelta[@"oldText"] isEqualToString:@""] && |
| [thirdDelta[@"deltaText"] isEqualToString:@"—"] && |
| [thirdDelta[@"deltaStart"] intValue] == 0 && |
| [thirdDelta[@"deltaEnd"] intValue] == 0; |
| }]]); |
| |
| // Simulate user input. |
| [inputView insertText:@"-"]; |
| [inputView deleteBackward]; |
| [inputView insertText:@"—"]; |
| |
| [self flushScheduledAsyncBlocks]; |
| OCMVerifyAll(engine); |
| } |
| |
| - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextReplacement { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"Some initial text"]; |
| XCTAssertEqual(updateCount, 0); |
| |
| UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; |
| inputView.markedTextRange = range; |
| inputView.selectedTextRange = nil; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView setMarkedText:@"new marked text." selectedRange:NSMakeRange(0, 1)]; |
| OCMVerify([engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"Some initial text"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"new marked text."]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); |
| }]]); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 2); |
| } |
| |
| - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextInsertion { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"Some initial text"]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 0); |
| |
| UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; |
| inputView.markedTextRange = range; |
| inputView.selectedTextRange = nil; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView setMarkedText:@"text." selectedRange:NSMakeRange(0, 1)]; |
| OCMVerify([engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"Some initial text"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"text."]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); |
| }]]); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 2); |
| } |
| |
| - (void)testTextEditingDeltasAreGeneratedOnSetMarkedTextDeletion { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"Some initial text"]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 0); |
| |
| UITextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(13, 4)]; |
| inputView.markedTextRange = range; |
| inputView.selectedTextRange = nil; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView setMarkedText:@"tex" selectedRange:NSMakeRange(0, 1)]; |
| OCMVerify([engine |
| flutterTextInputView:inputView |
| updateEditingClient:0 |
| withDelta:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([[state[@"deltas"] objectAtIndex:0][@"oldText"] |
| isEqualToString:@"Some initial text"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaText"] |
| isEqualToString:@"tex"]) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaStart"] intValue] == 13) && |
| ([[state[@"deltas"] objectAtIndex:0][@"deltaEnd"] intValue] == 17); |
| }]]); |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 2); |
| } |
| |
| #pragma mark - EditingState tests |
| |
| - (void)testUITextInputCallsUpdateEditingStateOnce { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView insertText:@"text to insert"]; |
| // Update the framework exactly once. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView deleteBackward]; |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| XCTAssertEqual(updateCount, 3); |
| |
| [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] |
| withText:@"replace text"]; |
| XCTAssertEqual(updateCount, 4); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| XCTAssertEqual(updateCount, 5); |
| |
| [inputView unmarkText]; |
| XCTAssertEqual(updateCount, 6); |
| } |
| |
| - (void)testUITextInputCallsUpdateEditingStateWithDeltaOnce { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView insertText:@"text to insert"]; |
| [self flushScheduledAsyncBlocks]; |
| // Update the framework exactly once. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView deleteBackward]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 3); |
| |
| [inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)] |
| withText:@"replace text"]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 4); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 5); |
| |
| [inputView unmarkText]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 6); |
| } |
| |
| - (void)testTextChangesDoNotTriggerUpdateEditingClient { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"BEFORE"]; |
| XCTAssertEqual(updateCount, 0); |
| |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Text changes don't trigger an update. |
| XCTAssertEqual(updateCount, 1); |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Selection changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Composing region changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}]; |
| XCTAssertEqual(updateCount, 1); |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}]; |
| XCTAssertEqual(updateCount, 1); |
| } |
| |
| - (void)testTextChangesDoNotTriggerUpdateEditingClientWithDelta { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| inputView.enableDeltaModel = YES; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withDelta:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView.text setString:@"BEFORE"]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 0); |
| |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Text changes don't trigger an update. |
| XCTAssertEqual(updateCount, 1); |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView setTextInputState:@{@"text" : @"AFTER"}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Selection changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @3}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @3}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| // Composing region changes don't trigger an update. |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @2}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}]; |
| [self flushScheduledAsyncBlocks]; |
| XCTAssertEqual(updateCount, 1); |
| } |
| |
| - (void)testUITextInputAvoidUnnecessaryUndateEditingClientCalls { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView updateEditingClient:0 withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView unmarkText]; |
| // updateEditingClient shouldn't fire as the text is already unmarked. |
| XCTAssertEqual(updateCount, 0); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| // updateEditingClient fires in response to setMarkedText. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView unmarkText]; |
| // updateEditingClient fires in response to unmarkText. |
| XCTAssertEqual(updateCount, 2); |
| } |
| |
| - (void)testCanCopyPasteWithScribbleEnabled { |
| if (@available(iOS 14.0, *)) { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [self setClientId:123 configuration:config]; |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| FlutterTextInputView* mockInputView = OCMPartialMock(inputView); |
| OCMStub([mockInputView isScribbleAvailable]).andReturn(YES); |
| |
| [mockInputView insertText:@"aaaa"]; |
| [mockInputView selectAll:nil]; |
| |
| XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]); |
| XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]); |
| XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:NULL]); |
| XCTAssertFalse([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]); |
| |
| [mockInputView copy:NULL]; |
| XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:NULL]); |
| XCTAssertTrue([mockInputView canPerformAction:@selector(copy:) withSender:@"sender"]); |
| XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:NULL]); |
| XCTAssertTrue([mockInputView canPerformAction:@selector(paste:) withSender:@"sender"]); |
| } |
| } |
| |
| - (void)testSetMarkedTextDuringScribbleDoesNotTriggerUpdateEditingClient { |
| if (@available(iOS 14.0, *)) { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| __block int updateCount = 0; |
| OCMStub([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg isNotNil]]) |
| .andDo(^(NSInvocation* invocation) { |
| updateCount++; |
| }); |
| |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| // updateEditingClient fires in response to setMarkedText. |
| XCTAssertEqual(updateCount, 1); |
| |
| UIScribbleInteraction* scribbleInteraction = |
| [[UIScribbleInteraction alloc] initWithDelegate:inputView]; |
| |
| [inputView scribbleInteractionWillBeginWriting:scribbleInteraction]; |
| [inputView setMarkedText:@"during writing" selectedRange:NSMakeRange(1, 2)]; |
| // updateEditingClient does not fire in response to setMarkedText during a scribble interaction. |
| XCTAssertEqual(updateCount, 1); |
| |
| [inputView scribbleInteractionDidFinishWriting:scribbleInteraction]; |
| [inputView resetScribbleInteractionStatusIfEnding]; |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| // updateEditingClient fires in response to setMarkedText. |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocusing; |
| [inputView setMarkedText:@"during focus" selectedRange:NSMakeRange(1, 2)]; |
| // updateEditingClient does not fire in response to setMarkedText during a scribble-initiated |
| // focus. |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusFocused; |
| [inputView setMarkedText:@"after focus" selectedRange:NSMakeRange(2, 3)]; |
| // updateEditingClient does not fire in response to setMarkedText after a scribble-initiated |
| // focus. |
| XCTAssertEqual(updateCount, 2); |
| |
| inputView.scribbleFocusStatus = FlutterScribbleFocusStatusUnfocused; |
| [inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)]; |
| // updateEditingClient fires in response to setMarkedText. |
| XCTAssertEqual(updateCount, 3); |
| } |
| } |
| |
| - (void)testUpdateEditingClientNegativeSelection { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| [inputView.text setString:@"SELECTION"]; |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @-1, |
| @"selectionExtent" : @-1 |
| }]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| // Returns (0, 0) when either end goes below 0. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @-1, @"selectionExtent" : @1}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @-1}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| } |
| |
| - (void)testUpdateEditingClientSelectionClamping { |
| // Regression test for https://github.com/flutter/flutter/issues/62992. |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| |
| [inputView.text setString:@"SELECTION"]; |
| inputView.markedTextRange = nil; |
| inputView.selectedTextRange = nil; |
| |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @0, @"selectionExtent" : @0}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 0); |
| }]]); |
| |
| // Needs clamping. |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @0, |
| @"selectionExtent" : @9999 |
| }]; |
| [inputView updateEditingState]; |
| |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 9); |
| }]]); |
| |
| // No clamping needed, but in reverse direction. |
| [inputView |
| setTextInputState:@{@"text" : @"SELECTION", @"selectionBase" : @1, @"selectionExtent" : @0}]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 0 && |
| ([state[@"selectionExtent"] intValue] == 1); |
| }]]); |
| |
| // Both ends need clamping. |
| [inputView setTextInputState:@{ |
| @"text" : @"SELECTION", |
| @"selectionBase" : @9999, |
| @"selectionExtent" : @9999 |
| }]; |
| [inputView updateEditingState]; |
| OCMVerify([engine flutterTextInputView:inputView |
| updateEditingClient:0 |
| withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) { |
| return ([state[@"selectionBase"] intValue]) == 9 && |
| ([state[@"selectionExtent"] intValue] == 9); |
| }]]); |
| } |
| |
| - (void)testInputViewsHasNonNilInputDelegate { |
| if (@available(iOS 13.0, *)) { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| NSAssert(inputView.isFirstResponder, @"inputView is not first responder"); |
| inputView.inputDelegate = nil; |
| |
| FlutterTextInputView* mockInputView = OCMPartialMock(inputView); |
| [mockInputView setTextInputState:@{ |
| @"text" : @"COMPOSING", |
| @"composingBase" : @1, |
| @"composingExtent" : @3 |
| }]; |
| OCMVerify([mockInputView setInputDelegate:[OCMArg isNotNil]]); |
| [inputView removeFromSuperview]; |
| } |
| } |
| |
| - (void)testInputViewsDoNotHaveUITextInteractions { |
| if (@available(iOS 13.0, *)) { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| BOOL hasTextInteraction = NO; |
| for (id interaction in inputView.interactions) { |
| hasTextInteraction = [interaction isKindOfClass:[UITextInteraction class]]; |
| if (hasTextInteraction) { |
| break; |
| } |
| } |
| XCTAssertFalse(hasTextInteraction); |
| } |
| } |
| |
| #pragma mark - UITextInput methods - Tests |
| |
| - (void)testUpdateFirstRectForRange { |
| [self setClientId:123 configuration:self.mutableTemplateCopy]; |
| |
| FlutterTextInputView* inputView = textInputPlugin.activeView; |
| textInputPlugin.viewController.view.frame = CGRectMake(0, 0, 0, 0); |
| |
| [inputView |
| setTextInputState:@{@"text" : @"COMPOSING", @"composingBase" : @1, @"composingExtent" : @3}]; |
| |
| CGRect kInvalidFirstRect = CGRectMake(-1, -1, 9999, 9999); |
| FlutterTextRange* range = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]; |
| // yOffset = 200. |
| NSArray* yOffsetMatrix = @[ @1, @0, @0, @0, @0, @1, @0, @0, @0, @0, @1, @0, @0, @200, @0, @1 ]; |
| NSArray* zeroMatrix = @[ @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0, @0 ]; |
| // This matrix can be generated by running this dart code snippet: |
| // Matrix4.identity()..scale(3.0)..rotateZ(math.pi/2)..translate(1.0, 2.0, |
| // 3.0); |
| NSArray* affineMatrix = @[ |
| @(0.0), @(3.0), @(0.0), @(0.0), @(-3.0), @(0.0), @(0.0), @(0.0), @(0.0), @(0.0), @(3.0), @(0.0), |
| @(-6.0), @(3.0), @(9.0), @(1.0) |
| ]; |
| |
| // Invalid since we don't have the transform or the rect. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [inputView firstRectForRange:range])); |
| |
| [inputView setEditableTransform:yOffsetMatrix]; |
| // Invalid since we don't have the rect. |
| XCTAssertTrue(CGRectEqualToRect(kInvalidFirstRect, [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])); |
| // Idempotent. |
| XCTAssertTrue(CGRectEqualToRect(finalRect, [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])); |
| |
| // Revert the invalid matrix change. |
| [inputView setEditableTransform:yOffsetMatrix]; |
| [inputView setMarkedRect:testRect]; |
| XCTAssertTrue(CGRectEqualToRect(finalRect, [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])); |
| |
| // 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])); |
| |
| NSAssert(inputView.superview, @"inputView is not in the view hierarchy!"); |
| const CGPoint offset = CGPointMake(113, 119); |
| CGRect currentFrame = inputView.frame; |
| currentFrame.origin = offset; |
| 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])); |
| } |
| |
| - (void)testFirstRectForRangeReturnsNoneZeroRectWhenScribbleIsEnabled { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; |
| |
| FlutterTextInputView* mockInputView = OCMPartialMock(inputView); |
| OCMStub([mockInputView isScribbleAvailable]).andReturn(YES); |
| |
| [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], |
| ]]; |
| |
| 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])); |
| } |
| } |
| |
| - (void)testFirstRectForRangeReturnsCorrectRectOnASingleLineLeftToRight { |
| 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], |
| ]]; |
| FlutterTextRange* singleRectRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 1)]; |
| if (@available(iOS 17, *)) { |
| XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), |
| [inputView firstRectForRange:singleRectRange])); |
| } else { |
| XCTAssertTrue(CGRectEqualToRect(CGRectZero, [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(CGRectZero, [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)]; |
| if (@available(iOS 17, *)) { |
| XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), |
| [inputView firstRectForRange:singleRectRange])); |
| } else { |
| XCTAssertTrue(CGRectEqualToRect(CGRectZero, [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(CGRectZero, [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)]; |
| if (@available(iOS 17, *)) { |
| XCTAssertTrue(CGRectEqualToRect(CGRectMake(100, 0, 100, 100), |
| [inputView firstRectForRange:singleRectRange])); |
| } else { |
| XCTAssertTrue(CGRectEqualToRect(CGRectZero, [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(CGRectZero, [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)]; |
| if (@available(iOS 17, *)) { |
| XCTAssertTrue(CGRectEqualToRect(CGRectMake(200, 0, 100, 100), |
| [inputView firstRectForRange:singleRectRange])); |
| } else { |
| XCTAssertTrue(CGRectEqualToRect(CGRectZero, [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(CGRectZero, [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(CGRectZero, [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(CGRectZero, [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(CGRectZero, [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(CGRectZero, [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(CGRectZero, [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(CGRectZero, [inputView firstRectForRange:multiRectRange])); |
| } |
| } |
| |
| - (void)testClosestPositionToPoint { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; |
| |
| // Minimize the vertical distance from the center of the rects first |
| [inputView setSelectionRects:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:2U], |
| ]]; |
| CGPoint point = CGPointMake(150, 150); |
| 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 |
| [inputView setSelectionRects:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], |
| ]]; |
| 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 |
| [inputView setSelectionRects:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 300, 100, 100) position:4U], |
| ]]; |
| point = CGPointMake(125, 201); |
| 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:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], |
| ]]; |
| 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 { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; |
| |
| CGRect testRect0 = CGRectMake(100, 100, 100, 100); |
| CGRect testRect1 = CGRectMake(200, 200, 100, 100); |
| [inputView setSelectionRects:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:testRect0 position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:testRect1 position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(300, 300, 100, 100) position:3U], |
| ]]; |
| |
| // Returns the matching rects within a range |
| 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]); |
| |
| // Returns a 0 width rect for a 0-length range |
| range = [FlutterTextRange rangeWithNSRange:NSMakeRange(1, 0)]; |
| XCTAssertEqual(1U, [[inputView selectionRectsForRange:range] count]); |
| XCTAssertTrue(CGRectEqualToRect( |
| CGRectMake(testRect0.origin.x, testRect0.origin.y, 0, testRect0.size.height), |
| [inputView selectionRectsForRange:range][0].rect)); |
| } |
| |
| - (void)testClosestPositionToPointWithinRange { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; |
| |
| // Do not return a position before the start of the range |
| [inputView setSelectionRects:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], |
| ]]; |
| CGPoint point = CGPointMake(125, 150); |
| 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:@[ |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 0, 100, 100) position:0U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 100, 100, 100) position:1U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(100, 100, 100, 100) position:2U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(200, 100, 100, 100) position:3U], |
| [FlutterTextSelectionRect selectionRectWithRect:CGRectMake(0, 200, 100, 100) position:4U], |
| ]]; |
| point = CGPointMake(125, 150); |
| 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); |
| } |
| |
| - (void)testClosestPositionToPointWithPartialSelectionRects { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{@"text" : @"COMPOSING"}]; |
| |
| [inputView setSelectionRects:@[ [FlutterTextSelectionRect |
| selectionRectWithRect:CGRectMake(0, 0, 100, 100) |
| position:0U] ]]; |
| // Asking with a position at the end of selection rects should give you the trailing edge of |
| // the last rect. |
| XCTAssertTrue(CGRectEqualToRect( |
| [inputView caretRectForPosition:[FlutterTextPosition |
| positionWithIndex:1 |
| affinity:UITextStorageDirectionForward]], |
| CGRectMake(100, 0, 0, 100))); |
| // Asking with a position beyond the end of selection rects should return CGRectZero without |
| // crashing. |
| XCTAssertTrue(CGRectEqualToRect( |
| [inputView caretRectForPosition:[FlutterTextPosition |
| positionWithIndex:2 |
| affinity:UITextStorageDirectionForward]], |
| CGRectZero)); |
| } |
| |
| #pragma mark - Floating Cursor - Tests |
| |
| - (void)testFloatingCursorDoesNotThrow { |
| // The keyboard implementation may send unbalanced calls to the input view. |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)]; |
| [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)]; |
| [inputView endFloatingCursor]; |
| [inputView beginFloatingCursorAtPoint:CGPointMake(123, 321)]; |
| [inputView endFloatingCursor]; |
| } |
| |
| - (void)testFloatingCursor { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView setTextInputState:@{ |
| @"text" : @"test", |
| @"selectionBase" : @1, |
| @"selectionExtent" : @1, |
| }]; |
| |
| 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 ]]; |
| |
| // 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)); |
| |
| // 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)); |
| |
| // 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)]); |
| }]]); |
| |
| [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 |
| |
| - (void)testInsertTextAddsPlaceholderSelectionRects { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView |
| setTextInputState:@{@"text" : @"test", @"selectionBase" : @1, @"selectionExtent" : @1}]; |
| |
| 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 ]]; |
| |
| // Inserts additional selection rects at the selection start |
| [inputView insertText:@"in"]; |
| NSArray* selectionRects = |
| [inputView selectionRectsForRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 6)]]; |
| XCTAssertEqual(6U, [selectionRects count]); |
| |
| XCTAssertEqual(first.position, ((FlutterTextSelectionRect*)selectionRects[0]).position); |
| XCTAssertTrue(CGRectEqualToRect(first.rect, ((FlutterTextSelectionRect*)selectionRects[0]).rect)); |
| |
| XCTAssertEqual(second.position, ((FlutterTextSelectionRect*)selectionRects[1]).position); |
| XCTAssertTrue( |
| CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[1]).rect)); |
| |
| XCTAssertEqual(second.position + 1, ((FlutterTextSelectionRect*)selectionRects[2]).position); |
| XCTAssertTrue( |
| CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[2]).rect)); |
| |
| XCTAssertEqual(second.position + 2, ((FlutterTextSelectionRect*)selectionRects[3]).position); |
| XCTAssertTrue( |
| CGRectEqualToRect(second.rect, ((FlutterTextSelectionRect*)selectionRects[3]).rect)); |
| |
| XCTAssertEqual(third.position + 2, ((FlutterTextSelectionRect*)selectionRects[4]).position); |
| XCTAssertTrue(CGRectEqualToRect(third.rect, ((FlutterTextSelectionRect*)selectionRects[4]).rect)); |
| |
| XCTAssertEqual(fourth.position + 2, ((FlutterTextSelectionRect*)selectionRects[5]).position); |
| XCTAssertTrue( |
| CGRectEqualToRect(fourth.rect, ((FlutterTextSelectionRect*)selectionRects[5]).rect)); |
| } |
| |
| #pragma mark - Autofill - Utilities |
| |
| - (NSMutableDictionary*)mutablePasswordTemplateCopy { |
| if (!_passwordTemplate) { |
| _passwordTemplate = @{ |
| @"inputType" : @{@"name" : @"TextInuptType.text"}, |
| @"keyboardAppearance" : @"Brightness.light", |
| @"obscureText" : @YES, |
| @"inputAction" : @"TextInputAction.unspecified", |
| @"smartDashesType" : @"0", |
| @"smartQuotesType" : @"0", |
| @"autocorrect" : @YES |
| }; |
| } |
| |
| return [_passwordTemplate mutableCopy]; |
| } |
| |
| - (NSArray<FlutterTextInputView*>*)viewsVisibleToAutofill { |
| return [self.installedInputViews |
| filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isVisibleToAutofill == YES"]]; |
| } |
| |
| - (void)commitAutofillContextAndVerify { |
| FlutterMethodCall* methodCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.finishAutofillContext" |
| arguments:@YES]; |
| [textInputPlugin handleMethodCall:methodCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, |
| [textInputPlugin.activeView isVisibleToAutofill] ? 1ul : 0ul); |
| XCTAssertNotEqual(textInputPlugin.textInputView, nil); |
| // The active view should still be installed so it doesn't get |
| // deallocated. |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul); |
| } |
| |
| #pragma mark - Autofill - Tests |
| |
| - (void)testDisablingAutofillOnInputClient { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| |
| [self setClientId:123 configuration:config]; |
| |
| FlutterTextInputView* inputView = self.installedInputViews[0]; |
| XCTAssertEqualObjects(inputView.textContentType, @""); |
| } |
| |
| - (void)testAutofillEnabledByDefault { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"NO" forKey:@"obscureText"]; |
| [config setValue:@{@"uniqueIdentifier" : @"field1", @"editingValue" : @{@"text" : @""}} |
| forKey:@"autofill"]; |
| |
| [self setClientId:123 configuration:config]; |
| |
| FlutterTextInputView* inputView = self.installedInputViews[0]; |
| XCTAssertNil(inputView.textContentType); |
| } |
| |
| - (void)testAutofillContext { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); |
| |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 2ul); |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // The configuration changes. |
| NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy; |
| [field3 setValue:@{ |
| @"uniqueIdentifier" : @"field3", |
| @"hints" : @[ @"hint3" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* oldContext = textInputPlugin.autofillContext; |
| // Replace field2 with field3. |
| [config setValue:@[ field1, field3 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 3ul); |
| XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| |
| // Switch to a password field that has no contentType and is not in an AutofillGroup. |
| config = self.mutablePasswordTemplateCopy; |
| |
| oldContext = textInputPlugin.autofillContext; |
| [self setClientId:124 configuration:config]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 4ul); |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| // The active view should change. |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Switch to a similar password field, the previous field should be reused. |
| oldContext = textInputPlugin.autofillContext; |
| [self setClientId:200 configuration:config]; |
| |
| // Reuse the input view instance from the last time. |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 3ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 4ul); |
| |
| // Old autofill input fields are still installed and reused. |
| for (NSString* key in oldContext.allKeys) { |
| XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]); |
| } |
| XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| } |
| |
| - (void)testCommitAutofillContext { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field3 = self.mutableTemplateCopy; |
| [field3 setValue:@{ |
| @"uniqueIdentifier" : @"field3", |
| @"hints" : @[ @"hint3" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Install the password field again. |
| [self setClientId:123 configuration:config]; |
| // Switch to a regular autofill group. |
| [self setClientId:124 configuration:field3]; |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 3ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul); |
| XCTAssertNotEqual(textInputPlugin.textInputView, nil); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Now switch to an input field that does not autofill. |
| [self setClientId:125 configuration:self.mutableTemplateCopy]; |
| |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 0ul); |
| // The active view should still be installed so it doesn't get |
| // deallocated. |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul); |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| [self commitAutofillContextAndVerify]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| } |
| |
| - (void)testAutofillInputViews { |
| NSMutableDictionary* field1 = self.mutableTemplateCopy; |
| [field1 setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint1" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* field2 = self.mutablePasswordTemplateCopy; |
| [field2 setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| |
| NSMutableDictionary* config = [field1 mutableCopy]; |
| [config setValue:@[ field1, field2 ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:config]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // Both fields are installed and visible because it's a password group. |
| XCTAssertEqual(inputFields.count, 2ul); |
| XCTAssertEqual(self.viewsVisibleToAutofill.count, 2ul); |
| |
| // Find the inactive autofillable input field. |
| FlutterTextInputView* inactiveView = inputFields[1]; |
| [inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)] |
| withText:@"Autofilled!"]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| // Verify behavior. |
| OCMVerify([engine flutterTextInputView:inactiveView |
| updateEditingClient:0 |
| withState:[OCMArg isNotNil] |
| withTag:@"field2"]); |
| } |
| |
| - (void)testPasswordAutofillHack { |
| NSDictionary* config = self.mutableTemplateCopy; |
| [config setValue:@"YES" forKey:@"obscureText"]; |
| [self setClientId:123 configuration:config]; |
| |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| FlutterTextInputView* inputView = inputFields[0]; |
| |
| XCTAssert([inputView isKindOfClass:[UITextField class]]); |
| // FlutterSecureTextInputView does not respond to font, |
| // but it should return the default UITextField.font. |
| XCTAssertNotEqual([inputView performSelector:@selector(font)], nil); |
| } |
| |
| - (void)testClearAutofillContextClearsSelection { |
| NSMutableDictionary* regularField = self.mutableTemplateCopy; |
| NSDictionary* editingValue = @{ |
| @"text" : @"REGULAR_TEXT_FIELD", |
| @"composingBase" : @0, |
| @"composingExtent" : @3, |
| @"selectionBase" : @1, |
| @"selectionExtent" : @4 |
| }; |
| [regularField setValue:@{ |
| @"uniqueIdentifier" : @"field2", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : editingValue, |
| } |
| forKey:@"autofill"]; |
| [regularField addEntriesFromDictionary:editingValue]; |
| [self setClientId:123 configuration:regularField]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| |
| FlutterTextInputView* oldInputView = self.installedInputViews[0]; |
| XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]); |
| FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange; |
| XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3))); |
| |
| // Replace the original password field with new one. This should remove |
| // the old password field, but not immediately. |
| [self setClientId:124 configuration:self.mutablePasswordTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 2ul); |
| |
| [textInputPlugin cleanUpViewHierarchy:NO clearText:YES delayRemoval:NO]; |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| |
| // Verify the old input view is properly cleaned up. |
| XCTAssert([oldInputView.text isEqualToString:@""]); |
| selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange; |
| XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0))); |
| } |
| |
| - (void)testGarbageInputViewsAreNotRemovedImmediately { |
| // Add a password field that should autofill. |
| [self setClientId:123 configuration:self.mutablePasswordTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| // Add an input field that doesn't autofill. This should remove the password |
| // field, but not immediately. |
| [self setClientId:124 configuration:self.mutableTemplateCopy]; |
| [self ensureOnlyActiveViewCanBecomeFirstResponder]; |
| |
| XCTAssertEqual(self.installedInputViews.count, 2ul); |
| |
| [self commitAutofillContextAndVerify]; |
| } |
| |
| - (void)testScribbleSetSelectionRects { |
| NSMutableDictionary* regularField = self.mutableTemplateCopy; |
| NSDictionary* editingValue = @{ |
| @"text" : @"REGULAR_TEXT_FIELD", |
| @"composingBase" : @0, |
| @"composingExtent" : @3, |
| @"selectionBase" : @1, |
| @"selectionExtent" : @4 |
| }; |
| [regularField setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ @"hint2" ], |
| @"editingValue" : editingValue, |
| } |
| forKey:@"autofill"]; |
| [regularField addEntriesFromDictionary:editingValue]; |
| [self setClientId:123 configuration:regularField]; |
| XCTAssertEqual(self.installedInputViews.count, 1ul); |
| XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 0u); |
| |
| NSArray<NSNumber*>* selectionRect = [NSArray arrayWithObjects:@0, @0, @100, @100, @0, @1, nil]; |
| NSArray* selectionRects = [NSArray arrayWithObjects:selectionRect, nil]; |
| FlutterMethodCall* methodCall = |
| [FlutterMethodCall methodCallWithMethodName:@"Scribble.setSelectionRects" |
| arguments:selectionRects]; |
| [textInputPlugin handleMethodCall:methodCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| XCTAssertEqual([textInputPlugin.activeView.selectionRects count], 1u); |
| } |
| |
| - (void)testDecommissionedViewAreNotReusedByAutofill { |
| // Regression test for https://github.com/flutter/flutter/issues/84407. |
| NSMutableDictionary* configuration = self.mutableTemplateCopy; |
| [configuration setValue:@{ |
| @"uniqueIdentifier" : @"field1", |
| @"hints" : @[ UITextContentTypePassword ], |
| @"editingValue" : @{@"text" : @""} |
| } |
| forKey:@"autofill"]; |
| [configuration setValue:@[ [configuration copy] ] forKey:@"fields"]; |
| |
| [self setClientId:123 configuration:configuration]; |
| |
| [self setTextInputHide]; |
| UIView* previousActiveView = textInputPlugin.activeView; |
| |
| [self setClientId:124 configuration:configuration]; |
| |
| // Make sure the autofillable view is reused. |
| XCTAssertEqual(previousActiveView, textInputPlugin.activeView); |
| XCTAssertNotNil(previousActiveView); |
| // Does not crash. |
| } |
| |
| - (void)testInitialActiveViewCantAccessTextInputDelegate { |
| // Before the framework sends the first text input configuration, |
| // the dummy "activeView" we use should never have access to |
| // its textInputDelegate. |
| XCTAssertNil(textInputPlugin.activeView.textInputDelegate); |
| } |
| |
| #pragma mark - Accessibility - Tests |
| |
| - (void)testUITextInputAccessibilityNotHiddenWhenShowed { |
| [self setClientId:123 configuration:self.mutableTemplateCopy]; |
| |
| // Send show text input method call. |
| [self setTextInputShow]; |
| // Find all the FlutterTextInputViews we created. |
| NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews; |
| |
| // The input view should not be hidden. |
| XCTAssertEqual([inputFields count], 1u); |
| |
| // Send hide text input method call. |
| [self setTextInputHide]; |
| |
| inputFields = self.installedInputViews; |
| |
| // The input view should be hidden. |
| XCTAssertEqual([inputFields count], 0u); |
| } |
| |
| - (void)testFlutterTextInputViewDirectFocusToBackingTextInput { |
| FlutterTextInputViewSpy* inputView = |
| [[FlutterTextInputViewSpy alloc] initWithOwner:textInputPlugin]; |
| UIView* container = [[UIView alloc] init]; |
| UIAccessibilityElement* backing = |
| [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container]; |
| inputView.backingTextInputAccessibilityObject = backing; |
| // Simulate accessibility focus. |
| inputView.isAccessibilityFocused = YES; |
| [inputView accessibilityElementDidBecomeFocused]; |
| |
| XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification); |
| XCTAssertEqual(inputView.receivedNotificationTarget, backing); |
| } |
| |
| - (void)testFlutterTokenizerCanParseLines { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| id<UITextInputTokenizer> tokenizer = [inputView tokenizer]; |
| |
| // The tokenizer returns zero range When text is empty. |
| FlutterTextRange* range = [self getLineRangeFromTokenizer:tokenizer atIndex:0]; |
| XCTAssertEqual(range.range.location, 0u); |
| XCTAssertEqual(range.range.length, 0u); |
| |
| [inputView insertText:@"how are you\nI am fine, Thank you"]; |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:0]; |
| XCTAssertEqual(range.range.location, 0u); |
| XCTAssertEqual(range.range.length, 11u); |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:2]; |
| XCTAssertEqual(range.range.location, 0u); |
| XCTAssertEqual(range.range.length, 11u); |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:11]; |
| XCTAssertEqual(range.range.location, 0u); |
| XCTAssertEqual(range.range.length, 11u); |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:12]; |
| XCTAssertEqual(range.range.location, 12u); |
| XCTAssertEqual(range.range.length, 20u); |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:15]; |
| XCTAssertEqual(range.range.location, 12u); |
| XCTAssertEqual(range.range.length, 20u); |
| |
| range = [self getLineRangeFromTokenizer:tokenizer atIndex:32]; |
| XCTAssertEqual(range.range.location, 12u); |
| XCTAssertEqual(range.range.length, 20u); |
| } |
| |
| - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInBackwardDirectionShouldNotReturnNil { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView insertText:@"0123456789\n012345"]; |
| id<UITextInputTokenizer> tokenizer = [inputView tokenizer]; |
| |
| FlutterTextRange* range = |
| (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument] |
| withGranularity:UITextGranularityLine |
| inDirection:UITextStorageDirectionBackward]; |
| XCTAssertEqual(range.range.location, 11u); |
| XCTAssertEqual(range.range.length, 6u); |
| } |
| |
| - (void)testFlutterTokenizerLineEnclosingEndOfDocumentInForwardDirectionShouldReturnNilOnIOS17 { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView insertText:@"0123456789\n012345"]; |
| id<UITextInputTokenizer> tokenizer = [inputView tokenizer]; |
| |
| FlutterTextRange* range = |
| (FlutterTextRange*)[tokenizer rangeEnclosingPosition:[inputView endOfDocument] |
| withGranularity:UITextGranularityLine |
| inDirection:UITextStorageDirectionForward]; |
| if (@available(iOS 17.0, *)) { |
| XCTAssertNil(range); |
| } else { |
| XCTAssertEqual(range.range.location, 11u); |
| XCTAssertEqual(range.range.length, 6u); |
| } |
| } |
| |
| - (void)testFlutterTokenizerLineEnclosingOutOfRangePositionShouldReturnNilOnIOS17 { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [inputView insertText:@"0123456789\n012345"]; |
| id<UITextInputTokenizer> tokenizer = [inputView tokenizer]; |
| |
| FlutterTextPosition* position = [FlutterTextPosition positionWithIndex:100]; |
| FlutterTextRange* range = |
| (FlutterTextRange*)[tokenizer rangeEnclosingPosition:position |
| withGranularity:UITextGranularityLine |
| inDirection:UITextStorageDirectionForward]; |
| if (@available(iOS 17.0, *)) { |
| XCTAssertNil(range); |
| } else { |
| XCTAssertEqual(range.range.location, 0u); |
| XCTAssertEqual(range.range.length, 0u); |
| } |
| } |
| |
| - (void)testFlutterTextInputPluginRetainsFlutterTextInputView { |
| FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; |
| FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; |
| myInputPlugin.viewController = flutterViewController; |
| |
| __weak UIView* activeView; |
| @autoreleasepool { |
| FlutterMethodCall* setClientCall = [FlutterMethodCall |
| methodCallWithMethodName:@"TextInput.setClient" |
| arguments:@[ |
| [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy |
| ]]; |
| [myInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| activeView = myInputPlugin.textInputView; |
| FlutterMethodCall* hideCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" |
| arguments:@[]]; |
| [myInputPlugin handleMethodCall:hideCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssertNotNil(activeView); |
| } |
| // This assert proves the myInputPlugin.textInputView is not deallocated. |
| XCTAssertNotNil(activeView); |
| } |
| |
| - (void)testFlutterTextInputPluginHostViewNilCrash { |
| FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; |
| myInputPlugin.viewController = nil; |
| XCTAssertThrows([myInputPlugin hostView], @"Throws exception if host view is nil"); |
| } |
| |
| - (void)testFlutterTextInputPluginHostViewNotNil { |
| FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; |
| FlutterEngine* flutterEngine = [[FlutterEngine alloc] init]; |
| [flutterEngine runWithEntrypoint:nil]; |
| flutterEngine.viewController = flutterViewController; |
| XCTAssertNotNil(flutterEngine.textInputPlugin.viewController); |
| XCTAssertNotNil([flutterEngine.textInputPlugin hostView]); |
| } |
| |
| - (void)testSetPlatformViewClient { |
| FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; |
| FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; |
| myInputPlugin.viewController = flutterViewController; |
| |
| FlutterMethodCall* setClientCall = [FlutterMethodCall |
| methodCallWithMethodName:@"TextInput.setClient" |
| arguments:@[ [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy ]]; |
| [myInputPlugin handleMethodCall:setClientCall |
| result:^(id _Nullable result){ |
| }]; |
| UIView* activeView = myInputPlugin.textInputView; |
| XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy."); |
| FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall |
| methodCallWithMethodName:@"TextInput.setPlatformViewClient" |
| arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}]; |
| [myInputPlugin handleMethodCall:setPlatformViewClientCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy."); |
| } |
| |
| - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| XCTAssert(inputView.isFirstResponder); |
| |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* onPointerMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssertFalse(inputView.isFirstResponder); |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot { |
| 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]; |
| |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| |
| if (textInputPlugin.keyboardView.superview != nil) { |
| for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) { |
| [subView removeFromSuperview]; |
| } |
| } |
| XCTAssert(textInputPlugin.keyboardView.superview == nil); |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* onPointerMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(510)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssertFalse(textInputPlugin.keyboardView.superview == nil); |
| for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) { |
| [subView removeFromSuperview]; |
| } |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll { |
| 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]; |
| |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* onPointerMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(510)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssert(textInputPlugin.keyboardView.superview != nil); |
| |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y); |
| |
| FlutterMethodCall* onPointerMoveCallMove = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(600)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCallMove |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssert(textInputPlugin.keyboardView.superview != nil); |
| |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0); |
| |
| for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) { |
| [subView removeFromSuperview]; |
| } |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll { |
| 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]; |
| |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* onPointerMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssert(textInputPlugin.keyboardView.superview != nil); |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y); |
| |
| FlutterMethodCall* onPointerMoveCallMove = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(600)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCallMove |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssert(textInputPlugin.keyboardView.superview != nil); |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, 600.0); |
| |
| FlutterMethodCall* onPointerMoveCallBackUp = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(10)}]; |
| [textInputPlugin handleMethodCall:onPointerMoveCallBackUp |
| result:^(id _Nullable result){ |
| }]; |
| XCTAssert(textInputPlugin.keyboardView.superview != nil); |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, keyboardFrame.origin.y); |
| for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) { |
| [subView removeFromSuperview]; |
| } |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardFindFirstResponderRecursive { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| |
| UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder; |
| XCTAssertEqualObjects(inputView, firstResponder); |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| FlutterTextInputView* subInputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| FlutterTextInputView* otherSubInputView = |
| [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| FlutterTextInputView* subFirstResponderInputView = |
| [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [subInputView addSubview:subFirstResponderInputView]; |
| [inputView addSubview:subInputView]; |
| [inputView addSubview:otherSubInputView]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [subInputView setTextInputClient:123]; |
| [subInputView reloadInputViews]; |
| [otherSubInputView setTextInputClient:123]; |
| [otherSubInputView reloadInputViews]; |
| [subFirstResponderInputView setTextInputClient:123]; |
| [subFirstResponderInputView reloadInputViews]; |
| [subFirstResponderInputView becomeFirstResponder]; |
| |
| UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder; |
| XCTAssertEqualObjects(subFirstResponderInputView, firstResponder); |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive { |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| |
| UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder; |
| XCTAssertNil(firstResponder); |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard { |
| 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]; |
| |
| XCTestExpectation* expectation = [[XCTestExpectation alloc] |
| initWithDescription: |
| @"didResignFirstResponder is called after screenshot keyboard dismissed."]; |
| OCMStub([engine flutterTextInputView:[OCMArg any] didResignFirstResponderWithTextInputClient:0]) |
| .andDo(^(NSInvocation* invocation) { |
| [expectation fulfill]; |
| }); |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* initialMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:initialMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* subsequentMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* pointerUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:pointerUpCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| [self waitForExpectations:@[ expectation ] timeout:2.0]; |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardScreenshotDismissedAfterPointerLiftedAboveMiddleYOfKeyboard { |
| 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]; |
| |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* initialMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:initialMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* subsequentMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* subsequentMoveBackUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(0)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveBackUpCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* pointerUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(0)}]; |
| [textInputPlugin handleMethodCall:pointerUpCall |
| result:^(id _Nullable result){ |
| }]; |
| NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) { |
| return textInputPlugin.keyboardViewContainer.subviews.count == 0; |
| }]; |
| XCTNSPredicateExpectation* expectation = |
| [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil]; |
| [self waitForExpectations:@[ expectation ] timeout:10.0]; |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardKeyboardReappearsAfterPointerLiftedAboveMiddleYOfKeyboard { |
| 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]; |
| |
| FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; |
| [UIApplication.sharedApplication.keyWindow addSubview:inputView]; |
| |
| [inputView setTextInputClient:123]; |
| [inputView reloadInputViews]; |
| [inputView becomeFirstResponder]; |
| |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* initialMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:initialMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* subsequentMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* subsequentMoveBackUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(0)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveBackUpCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* pointerUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(0)}]; |
| [textInputPlugin handleMethodCall:pointerUpCall |
| result:^(id _Nullable result){ |
| }]; |
| NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) { |
| return textInputPlugin.cachedFirstResponder.isFirstResponder; |
| }]; |
| XCTNSPredicateExpectation* expectation = |
| [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil]; |
| [self waitForExpectations:@[ expectation ] timeout:10.0]; |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardKeyboardAnimatesToOriginalPositionalOnPointerUp { |
| 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]; |
| |
| XCTestExpectation* expectation = |
| [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."]; |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* initialMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:initialMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* subsequentMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* upwardVelocityMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:upwardVelocityMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* pointerUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(0)}]; |
| [textInputPlugin |
| handleMethodCall:pointerUpCall |
| result:^(id _Nullable result) { |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, |
| viewController.flutterScreenIfViewLoaded.bounds.size.height - |
| keyboardFrame.origin.y); |
| [expectation fulfill]; |
| }]; |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| |
| - (void)testInteractiveKeyboardKeyboardAnimatesToDismissalPositionalOnPointerUp { |
| 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]; |
| |
| XCTestExpectation* expectation = |
| [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."]; |
| CGRect keyboardFrame = CGRectMake(0, 500, 500, 500); |
| [NSNotificationCenter.defaultCenter |
| postNotificationName:UIKeyboardWillShowNotification |
| object:nil |
| userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}]; |
| FlutterMethodCall* initialMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(500)}]; |
| [textInputPlugin handleMethodCall:initialMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| FlutterMethodCall* subsequentMoveCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin handleMethodCall:subsequentMoveCall |
| result:^(id _Nullable result){ |
| }]; |
| |
| FlutterMethodCall* pointerUpCall = |
| [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard" |
| arguments:@{@"pointerY" : @(1000)}]; |
| [textInputPlugin |
| handleMethodCall:pointerUpCall |
| result:^(id _Nullable result) { |
| XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y, |
| viewController.flutterScreenIfViewLoaded.bounds.size.height); |
| [expectation fulfill]; |
| }]; |
| textInputPlugin.cachedFirstResponder = nil; |
| } |
| - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsNotImmediatelyEnable { |
| [UIView setAnimationsEnabled:YES]; |
| [textInputPlugin showKeyboardAndRemoveScreenshot]; |
| XCTAssertFalse( |
| UIView.areAnimationsEnabled, |
| @"The animation should still be disabled following showKeyboardAndRemoveScreenshot"); |
| } |
| |
| - (void)testInteractiveKeyboardShowKeyboardAndRemoveScreenshotAnimationIsReenabledAfterDelay { |
| [UIView setAnimationsEnabled:YES]; |
| [textInputPlugin showKeyboardAndRemoveScreenshot]; |
| |
| NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) { |
| // This will be enabled after a delay |
| return UIView.areAnimationsEnabled; |
| }]; |
| XCTNSPredicateExpectation* expectation = |
| [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil]; |
| [self waitForExpectations:@[ expectation ] timeout:10.0]; |
| } |
| |
| @end |