-
Notifications
You must be signed in to change notification settings - Fork 6k
Add autofill support to ios text input plugin #17493
Changes from 8 commits
053b17e
8f828bf
60af9a5
0d195a6
e87f36f
c306f8c
3817e3d
1f3b53b
7a4bef4
8894ccb
bec64d3
57bf5a5
7ea6749
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,9 @@ | |
| #include "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" | ||
| #include "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" | ||
|
|
||
| #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI: This will be unnecessary after #17624 lands
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was talking about the "FLUTTER_EXPORT" not being needed once that lands. We used to have to export things we wanted to tests, now tests live with the code so it isn't necessary. |
||
| FLUTTER_EXPORT | ||
| #endif | ||
| @interface FlutterTextInputPlugin : NSObject | ||
|
|
||
| @property(nonatomic, assign) id<FlutterTextInputDelegate> textInputDelegate; | ||
|
|
@@ -71,6 +74,7 @@ FLUTTER_EXPORT | |
| @property(nonatomic, getter=isSecureTextEntry) BOOL secureTextEntry; | ||
| @property(nonatomic) UITextSmartQuotesType smartQuotesType API_AVAILABLE(ios(11.0)); | ||
| @property(nonatomic) UITextSmartDashesType smartDashesType API_AVAILABLE(ios(11.0)); | ||
| @property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0)); | ||
|
|
||
| @property(nonatomic, assign) id<FlutterTextInputDelegate> textInputDelegate; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,97 @@ static UIReturnKeyType ToUIReturnKeyType(NSString* inputType) { | |
| return UIReturnKeyDefault; | ||
| } | ||
|
|
||
| static UITextContentType ToUITextContentType(NSArray<NSString*>* hints) { | ||
| if (hints == nil || hints.count == 0) | ||
| return @""; | ||
|
|
||
| NSString* hint = hints[0]; | ||
| if (@available(iOS 10.0, *)) { | ||
| if ([hint isEqualToString:@"addressCityAndState"]) | ||
| return UITextContentTypeAddressCityAndState; | ||
|
|
||
| if ([hint isEqualToString:@"addressState"]) | ||
| return UITextContentTypeAddressState; | ||
|
|
||
| if ([hint isEqualToString:@"addressCity"]) | ||
| return UITextContentTypeAddressCity; | ||
|
|
||
| if ([hint isEqualToString:@"sublocality"]) | ||
| return UITextContentTypeSublocality; | ||
|
|
||
| if ([hint isEqualToString:@"streetAddressLine1"]) | ||
| return UITextContentTypeStreetAddressLine1; | ||
|
|
||
| if ([hint isEqualToString:@"streetAddressLine2"]) | ||
| return UITextContentTypeStreetAddressLine2; | ||
|
|
||
| if ([hint isEqualToString:@"countryName"]) | ||
| return UITextContentTypeCountryName; | ||
|
|
||
| if ([hint isEqualToString:@"fullStreetAddress"]) | ||
| return UITextContentTypeFullStreetAddress; | ||
|
|
||
| if ([hint isEqualToString:@"postalCode"]) | ||
| return UITextContentTypePostalCode; | ||
|
|
||
| if ([hint isEqualToString:@"location"]) | ||
| return UITextContentTypeLocation; | ||
|
|
||
| if ([hint isEqualToString:@"creditCardNumber"]) | ||
| return UITextContentTypeCreditCardNumber; | ||
|
|
||
| if ([hint isEqualToString:@"email"]) | ||
| return UITextContentTypeEmailAddress; | ||
|
|
||
| if ([hint isEqualToString:@"jobTitle"]) | ||
| return UITextContentTypeJobTitle; | ||
|
|
||
| if ([hint isEqualToString:@"givenName"]) | ||
| return UITextContentTypeGivenName; | ||
|
|
||
| if ([hint isEqualToString:@"middleName"]) | ||
| return UITextContentTypeMiddleName; | ||
|
|
||
| if ([hint isEqualToString:@"familyName"]) | ||
| return UITextContentTypeFamilyName; | ||
|
|
||
| if ([hint isEqualToString:@"name"]) | ||
| return UITextContentTypeName; | ||
|
|
||
| if ([hint isEqualToString:@"namePrefix"]) | ||
| return UITextContentTypeNamePrefix; | ||
|
|
||
| if ([hint isEqualToString:@"nameSuffix"]) | ||
| return UITextContentTypeNameSuffix; | ||
|
|
||
| if ([hint isEqualToString:@"nickname"]) | ||
| return UITextContentTypeNickname; | ||
|
|
||
| if ([hint isEqualToString:@"organizationName"]) | ||
| return UITextContentTypeOrganizationName; | ||
|
|
||
| if ([hint isEqualToString:@"telephoneNumber"]) | ||
| return UITextContentTypeTelephoneNumber; | ||
| } | ||
|
|
||
| if (@available(iOS 11.0, *)) { | ||
| if ([hint isEqualToString:@"password"]) | ||
| return UITextContentTypePassword; | ||
| } | ||
|
|
||
| if (@available(iOS 12.0, *)) { | ||
| if ([hint isEqualToString:@"oneTimeCode"]) | ||
| return UITextContentTypeOneTimeCode; | ||
| } | ||
|
|
||
| return hints[0]; | ||
| } | ||
|
|
||
| static NSString* _uniqueIdFromDictionary(NSDictionary* dictionary) { | ||
| NSDictionary* autofill = dictionary[@"autofill"]; | ||
| return autofill == nil ? nil : autofill[@"uniqueIdentifier"]; | ||
| } | ||
|
|
||
| #pragma mark - FlutterTextPosition | ||
|
|
||
| @implementation FlutterTextPosition | ||
|
|
@@ -141,6 +232,7 @@ - (id)copyWithZone:(NSZone*)zone { | |
| @end | ||
|
|
||
| @implementation FlutterTextInputView { | ||
| NSString* _autofillId; | ||
| int _textInputClient; | ||
| const char* _selectionAffinity; | ||
| FlutterTextRange* _selectedTextRange; | ||
|
|
@@ -184,13 +276,18 @@ - (void)dealloc { | |
| [_markedTextRange release]; | ||
| [_selectedTextRange release]; | ||
| [_tokenizer release]; | ||
| [_autofillId release]; | ||
| [super dealloc]; | ||
| } | ||
|
|
||
| - (void)setTextInputClient:(int)client { | ||
| _textInputClient = client; | ||
| } | ||
|
|
||
| - (void)setAutofillId:(NSString*)autofillId { | ||
| _autofillId = [autofillId copy]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't release the old value. Instead of writing this you can just do
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you for the review! I have to put the interface in the header file in order to be able to import that in a unit test (at least for now). Does adding
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh your pull request is already merged. I guess that means I can move the interface back to the .mm file?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, autorelease can be used on any object. |
||
| } | ||
|
|
||
| - (void)setTextInputState:(NSDictionary*)state { | ||
| NSString* newText = state[@"text"]; | ||
| BOOL textChanged = ![self.text isEqualToString:newText]; | ||
|
|
@@ -241,7 +338,10 @@ - (NSRange)clampSelection:(NSRange)range forText:(NSString*)text { | |
| #pragma mark - UIResponder Overrides | ||
|
|
||
| - (BOOL)canBecomeFirstResponder { | ||
| return YES; | ||
| // Only the currently focused input field can | ||
| // become the first responder. This prevents iOS | ||
| // from changing focus by itself. | ||
| return _textInputClient != 0; | ||
| } | ||
|
|
||
| #pragma mark - UITextInput Overrides | ||
|
|
@@ -600,16 +700,21 @@ - (void)updateEditingState { | |
| composingBase = ((FlutterTextPosition*)self.markedTextRange.start).index; | ||
| composingExtent = ((FlutterTextPosition*)self.markedTextRange.end).index; | ||
| } | ||
| [_textInputDelegate updateEditingClient:_textInputClient | ||
| withState:@{ | ||
| @"selectionBase" : @(selectionBase), | ||
| @"selectionExtent" : @(selectionExtent), | ||
| @"selectionAffinity" : @(_selectionAffinity), | ||
| @"selectionIsDirectional" : @(false), | ||
| @"composingBase" : @(composingBase), | ||
| @"composingExtent" : @(composingExtent), | ||
| @"text" : [NSString stringWithString:self.text], | ||
| }]; | ||
|
|
||
| NSDictionary* state = @{ | ||
| @"selectionBase" : @(selectionBase), | ||
| @"selectionExtent" : @(selectionExtent), | ||
| @"selectionAffinity" : @(_selectionAffinity), | ||
| @"selectionIsDirectional" : @(false), | ||
| @"composingBase" : @(composingBase), | ||
| @"composingExtent" : @(composingExtent), | ||
| @"text" : [NSString stringWithString:self.text], | ||
| }; | ||
|
|
||
| if (_textInputClient == 0 && _autofillId != nil) | ||
| [_textInputDelegate updateEditingClient:_textInputClient withState:state withTag:_autofillId]; | ||
| else | ||
| [_textInputDelegate updateEditingClient:_textInputClient withState:state]; | ||
| } | ||
|
|
||
| - (BOOL)hasText { | ||
|
|
@@ -672,8 +777,10 @@ - (BOOL)accessibilityElementsHidden { | |
| @end | ||
|
|
||
| @implementation FlutterTextInputPlugin { | ||
| FlutterTextInputView* _view; | ||
| FlutterTextInputView* _secureView; | ||
| FlutterTextInputView* _nonAutofillInputView; | ||
| FlutterTextInputView* _nonAutofillSecureInputView; | ||
|
|
||
| NSMutableArray<FlutterTextInputView*>* _inputViews; | ||
| FlutterTextInputView* _activeView; | ||
| FlutterTextInputViewAccessibilityHider* _inputHider; | ||
| } | ||
|
|
@@ -684,12 +791,13 @@ - (instancetype)init { | |
| self = [super init]; | ||
|
|
||
| if (self) { | ||
| _view = [[FlutterTextInputView alloc] init]; | ||
| _view.secureTextEntry = NO; | ||
| _secureView = [[FlutterTextInputView alloc] init]; | ||
| _secureView.secureTextEntry = YES; | ||
| _nonAutofillInputView = [[FlutterTextInputView alloc] init]; | ||
| _nonAutofillInputView.secureTextEntry = NO; | ||
| _nonAutofillInputView = [[FlutterTextInputView alloc] init]; | ||
| _nonAutofillSecureInputView.secureTextEntry = YES; | ||
| _inputViews = [[NSMutableArray alloc] init]; | ||
|
|
||
| _activeView = _view; | ||
| _activeView = _nonAutofillInputView; | ||
| _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init]; | ||
| } | ||
|
|
||
|
|
@@ -698,9 +806,10 @@ - (instancetype)init { | |
|
|
||
| - (void)dealloc { | ||
| [self hideTextInput]; | ||
| [_view release]; | ||
| [_secureView release]; | ||
| [_nonAutofillInputView release]; | ||
| [_nonAutofillSecureInputView release]; | ||
| [_inputHider release]; | ||
| [_inputViews release]; | ||
|
|
||
| [super dealloc]; | ||
| } | ||
|
|
@@ -733,58 +842,110 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { | |
| } | ||
|
|
||
| - (void)showTextInput { | ||
| NSAssert([UIApplication sharedApplication].keyWindow != nullptr, | ||
| UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow; | ||
| NSAssert(keyWindow != nullptr, | ||
| @"The application must have a key window since the keyboard client " | ||
| @"must be part of the responder chain to function"); | ||
| _activeView.textInputDelegate = _textInputDelegate; | ||
| [_inputHider addSubview:_activeView]; | ||
| [[UIApplication sharedApplication].keyWindow addSubview:_inputHider]; | ||
| if (![_activeView isDescendantOfView:_inputHider]) { | ||
| [_inputHider addSubview:_activeView]; | ||
| } | ||
| [keyWindow addSubview:_inputHider]; | ||
| [_activeView becomeFirstResponder]; | ||
| } | ||
|
|
||
| - (void)hideTextInput { | ||
| [_activeView resignFirstResponder]; | ||
| [_activeView removeFromSuperview]; | ||
| [_inputHider removeFromSuperview]; | ||
| } | ||
|
|
||
| - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { | ||
| NSDictionary* inputType = configuration[@"inputType"]; | ||
| NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; | ||
| if ([configuration[@"obscureText"] boolValue]) { | ||
| _activeView = _secureView; | ||
| NSArray* fields = configuration[@"fields"]; | ||
| NSString* clientUniqueId = _uniqueIdFromDictionary(configuration); | ||
| bool isSecureTextEntry = [configuration[@"obscureText"] boolValue]; | ||
|
|
||
| if (fields == nil) { | ||
| _activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView; | ||
| [FlutterTextInputPlugin setupInputView:_activeView WithConfiguration:configuration]; | ||
|
|
||
| if (![_activeView isDescendantOfView:_inputHider]) { | ||
| [_inputHider addSubview:_activeView]; | ||
| } | ||
| } else { | ||
| _activeView = _view; | ||
| NSAssert(clientUniqueId != nil, @"The client's unique id can't be null"); | ||
| for (FlutterTextInputView* v in _inputViews) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. http://google.github.io/styleguide/objcguide.html#conditionals |
||
| [v removeFromSuperview]; | ||
| for (UIView* subview in [_inputHider subviews]) | ||
| [subview removeFromSuperview]; | ||
|
|
||
| [_inputViews removeAllObjects]; | ||
|
|
||
| for (NSDictionary* field in fields) { | ||
| FlutterTextInputView* newInputView = [[FlutterTextInputView alloc] init]; | ||
| newInputView.textInputDelegate = _textInputDelegate; | ||
| [_inputViews addObject:newInputView]; | ||
|
gaaclarke marked this conversation as resolved.
|
||
|
|
||
| NSString* autofillId = _uniqueIdFromDictionary(field); | ||
| [newInputView setAutofillId:autofillId]; | ||
|
|
||
| if ([clientUniqueId isEqualToString:autofillId]) | ||
| _activeView = newInputView; | ||
|
|
||
| [FlutterTextInputPlugin setupInputView:newInputView WithConfiguration:field]; | ||
| [_inputHider addSubview:newInputView]; | ||
| } | ||
| } | ||
|
|
||
| _activeView.keyboardType = ToUIKeyboardType(inputType); | ||
| _activeView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); | ||
| _activeView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); | ||
| [_activeView setTextInputClient:client]; | ||
| [_activeView reloadInputViews]; | ||
| } | ||
|
|
||
| + (void)setupInputView:(FlutterTextInputView*)inputView | ||
| WithConfiguration:(NSDictionary*)configuration { | ||
| NSDictionary* inputType = configuration[@"inputType"]; | ||
| NSString* keyboardAppearance = configuration[@"keyboardAppearance"]; | ||
| NSDictionary* autofill = configuration[@"autofill"]; | ||
|
|
||
| inputView.secureTextEntry = [configuration[@"obscureText"] boolValue]; | ||
| inputView.keyboardType = ToUIKeyboardType(inputType); | ||
| inputView.returnKeyType = ToUIReturnKeyType(configuration[@"inputAction"]); | ||
| inputView.autocapitalizationType = ToUITextAutoCapitalizationType(configuration); | ||
|
|
||
| if (@available(iOS 11.0, *)) { | ||
| NSString* smartDashesType = configuration[@"smartDashesType"]; | ||
| // This index comes from the SmartDashesType enum in the framework. | ||
| bool smartDashesIsDisabled = smartDashesType && [smartDashesType isEqualToString:@"0"]; | ||
| _activeView.smartDashesType = | ||
| inputView.smartDashesType = | ||
| smartDashesIsDisabled ? UITextSmartDashesTypeNo : UITextSmartDashesTypeYes; | ||
| NSString* smartQuotesType = configuration[@"smartQuotesType"]; | ||
| // This index comes from the SmartQuotesType enum in the framework. | ||
| bool smartQuotesIsDisabled = smartQuotesType && [smartQuotesType isEqualToString:@"0"]; | ||
| _activeView.smartQuotesType = | ||
| inputView.smartQuotesType = | ||
| smartQuotesIsDisabled ? UITextSmartQuotesTypeNo : UITextSmartQuotesTypeYes; | ||
| } | ||
| if ([keyboardAppearance isEqualToString:@"Brightness.dark"]) { | ||
| _activeView.keyboardAppearance = UIKeyboardAppearanceDark; | ||
| inputView.keyboardAppearance = UIKeyboardAppearanceDark; | ||
| } else if ([keyboardAppearance isEqualToString:@"Brightness.light"]) { | ||
| _activeView.keyboardAppearance = UIKeyboardAppearanceLight; | ||
| inputView.keyboardAppearance = UIKeyboardAppearanceLight; | ||
| } else { | ||
| _activeView.keyboardAppearance = UIKeyboardAppearanceDefault; | ||
| inputView.keyboardAppearance = UIKeyboardAppearanceDefault; | ||
| } | ||
| NSString* autocorrect = configuration[@"autocorrect"]; | ||
| _activeView.autocorrectionType = autocorrect && ![autocorrect boolValue] | ||
| ? UITextAutocorrectionTypeNo | ||
| : UITextAutocorrectionTypeDefault; | ||
| [_activeView setTextInputClient:client]; | ||
| [_activeView reloadInputViews]; | ||
| inputView.autocorrectionType = autocorrect && ![autocorrect boolValue] | ||
| ? UITextAutocorrectionTypeNo | ||
| : UITextAutocorrectionTypeDefault; | ||
| if (@available(iOS 10.0, *)) { | ||
| if (autofill == nil) { | ||
| inputView.textContentType = @""; | ||
| } else { | ||
| inputView.textContentType = ToUITextContentType(autofill[@"hints"]); | ||
| [inputView setTextInputState:autofill[@"editingValue"]]; | ||
| // An input field needs to be visible in order to get | ||
| // autofilled when it's not the one that triggered | ||
| // autofill. | ||
| inputView.frame = CGRectMake(0, 0, 1, 1); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| - (void)setTextInputEditingState:(NSDictionary*)state { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be safer if you used performSelector:withObject:withObject: https://developer.apple.com/documentation/objectivec/1418956-nsobject/1418667-performselector?language=objc
That will give you static analysis that the method you are invoking exists.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think
@"TextInputClient.updateEditingStateWithTag"does not represent an objective-c selector but a platform message (that will be received and handled by some dart code)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry, you're right.