Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 88 additions & 47 deletions shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ @implementation FlutterTextInputView {
const char* _selectionAffinity;
FlutterTextRange* _selectedTextRange;
CGRect _cachedFirstRect;
// The view has reached end of life, and is no longer
// allowed to access its textInputDelegate.
BOOL _decommissioned;
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need this extra state, wouldn't removal of the FlutterTextInputView be enough to say it's decommissioned?

Copy link
Member

Choose a reason for hiding this comment

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

It seems like the crux of your problem was the reuse of the views, right? Which you removed elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The crux of the problem is UIKit actually holds onto "decommissioned" views (e.g. UITextInteraction may keep a view alive after it's removed from its superview) and attempts to change the selection in the view (as a result the view will try to access the engine and send the update to the framework).

Copy link
Member

Choose a reason for hiding this comment

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

I see, do we know who is holding onto the view? It might just be a matter of clearing out it's state as well. _textInterfaction.view = nil?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(Sorry had to context switch). I think I tried that but didn't work. UITextInteraction.textInput is a weak ref: https://developer.apple.com/documentation/uikit/uitextinteraction/3255084-textinput?language=objc, UIKit seems to be retaining it somewhere else and removing the weak ref doesn't seem to do anything other than setting it to nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure there's a public API that allows us to release the reference. I think technically it's reasonable for uikit to retain a strong ref to a UITextInput instance until it's not needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

regarding setting _textInputDelegate instead (the first comment), I thought about that but slightly prefer having an additional lifecycle tracking variable that's irreversible. Then I'll be able to add asserts to verify that decommissioned views are not gonna get reused. But no strong preference.

Copy link
Member

Choose a reason for hiding this comment

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

Okay, it's kind of weird but sounds like you've looking into all the alternatives so I can go along with this. Thanks for looking into it.

}

@synthesize tokenizer = _tokenizer;
Expand Down Expand Up @@ -535,6 +538,7 @@ - (instancetype)init {
_returnKeyType = UIReturnKeyDone;
_secureTextEntry = NO;
_accessibilityEnabled = NO;
_decommissioned = NO;
if (@available(iOS 11.0, *)) {
_smartQuotesType = UITextSmartQuotesTypeYes;
_smartDashesType = UITextSmartDashesTypeYes;
Expand All @@ -545,6 +549,7 @@ - (instancetype)init {
}

- (void)configureWithDictionary:(NSDictionary*)configuration {
NSAssert(!_decommissioned, @"Attempt to reuse a decommissioned view, for %@", configuration);
NSDictionary* inputType = configuration[kKeyboardType];
NSString* keyboardAppearance = configuration[kKeyboardAppearance];
NSDictionary* autofill = configuration[kAutofillProperties];
Expand Down Expand Up @@ -596,6 +601,23 @@ - (UITextContentType)textContentType {
return _textContentType;
}

- (id<FlutterTextInputDelegate>)textInputDelegate {
return _decommissioned ? nil : _textInputDelegate;
}

// Declares that the view has reached end of life, and
// is no longer allowed to access its textInputDelegate.
//
// UIKit may retain this view (even after it's been removed
// from the view hierarchy) so that it may outlive the plugin/engine,
// in which case _textInputDelegate will become a dangling pointer.

// The text input plugin needs to call decommision when it should
// not have access to its FlutterTextInputDelegate any more.
- (void)decommision {
_decommissioned = YES;
}

- (void)dealloc {
[_text release];
[_markedText release];
Expand Down Expand Up @@ -778,7 +800,8 @@ - (void)replaceRange:(UITextRange*)range withText:(NSString*)text {

- (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text {
if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) {
[_textInputDelegate performAction:FlutterTextInputActionNewline withClient:_textInputClient];
[self.textInputDelegate performAction:FlutterTextInputActionNewline
withClient:_textInputClient];
return YES;
}

Expand Down Expand Up @@ -819,7 +842,7 @@ - (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)t
break;
}

[_textInputDelegate performAction:action withClient:_textInputClient];
[self.textInputDelegate performAction:action withClient:_textInputClient];
return NO;
}

Expand Down Expand Up @@ -1062,9 +1085,9 @@ - (CGRect)firstRectForRange:(UITextRange*)range {
return _cachedFirstRect;
}

[_textInputDelegate showAutocorrectionPromptRectForStart:start
end:end
withClient:_textInputClient];
[self.textInputDelegate showAutocorrectionPromptRectForStart:start
end:end
withClient:_textInputClient];
// TODO(cbracken) Implement.
return CGRectZero;
}
Expand Down Expand Up @@ -1097,21 +1120,21 @@ - (UITextRange*)characterRangeAtPoint:(CGPoint)point {
}

- (void)beginFloatingCursorAtPoint:(CGPoint)point {
[_textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateStart
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateStart
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
}

- (void)updateFloatingCursorAtPoint:(CGPoint)point {
[_textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateUpdate
withClient:_textInputClient
withPosition:@{@"X" : @(point.x), @"Y" : @(point.y)}];
}

- (void)endFloatingCursor {
[_textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateEnd
withClient:_textInputClient
withPosition:@{@"X" : @(0), @"Y" : @(0)}];
[self.textInputDelegate updateFloatingCursor:FlutterFloatingCursorDragStateEnd
withClient:_textInputClient
withPosition:@{@"X" : @(0), @"Y" : @(0)}];
}

#pragma mark - UIKeyInput Overrides
Expand Down Expand Up @@ -1139,9 +1162,11 @@ - (void)updateEditingState {
};

if (_textInputClient == 0 && _autofillId != nil) {
[_textInputDelegate updateEditingClient:_textInputClient withState:state withTag:_autofillId];
[self.textInputDelegate updateEditingClient:_textInputClient
withState:state
withTag:_autofillId];
} else {
[_textInputDelegate updateEditingClient:_textInputClient withState:state];
[self.textInputDelegate updateEditingClient:_textInputClient withState:state];
}
}

Expand Down Expand Up @@ -1259,8 +1284,6 @@ - (void)enableActiveViewAccessibility {
@end

@interface FlutterTextInputPlugin ()
@property(nonatomic, strong) FlutterTextInputView* reusableInputView;

// The current password-autofillable input fields that have yet to be saved.
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
Expand All @@ -1278,11 +1301,11 @@ - (instancetype)init {
self = [super init];

if (self) {
_reusableInputView = [[FlutterTextInputView alloc] init];
_reusableInputView.secureTextEntry = NO;
_autofillContext = [[NSMutableDictionary alloc] init];
_activeView = [_reusableInputView retain];
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
// Initialize activeView with a dummy view to keep tests
// passing.
_activeView = [[FlutterTextInputView alloc] init];
}

return self;
Expand All @@ -1291,7 +1314,6 @@ - (instancetype)init {
- (void)dealloc {
[self hideTextInput];
_activeView.textInputDelegate = nil;
[_reusableInputView release];
[_activeView release];
[_inputHider release];
[_autofillContext release];
Expand Down Expand Up @@ -1398,14 +1420,14 @@ - (void)triggerAutofillSave:(BOOL)saveEntries {
if (saveEntries) {
// Make all the input fields in the autofill context visible,
// then remove them to trigger autofill save.
[self cleanUpViewHierarchy:YES clearText:YES];
[self cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO];
[_autofillContext removeAllObjects];
[self changeInputViewsAutofillVisibility:YES];
} else {
[_autofillContext removeAllObjects];
}

[self cleanUpViewHierarchy:YES clearText:!saveEntries];
[self cleanUpViewHierarchy:YES clearText:!saveEntries delayRemoval:NO];
[self addToInputParentViewIfNeeded:_activeView];
}

Expand All @@ -1414,9 +1436,11 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
// Hide all input views from autofill, only make those in the new configuration visible
// to autofill.
[self changeInputViewsAutofillVisibility:NO];

// Update the current active view.
switch (autofillTypeOf(configuration)) {
case FlutterAutofillTypeNone:
self.activeView = [self updateAndShowReusableInputView:configuration];
self.activeView = [self createInputViewWith:configuration];
break;
case FlutterAutofillTypeRegular:
// If the group does not involve password autofill, only install the
Expand All @@ -1431,7 +1455,6 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
isPasswordRelated:YES];
break;
}

[_activeView setTextInputClient:client];
[_activeView reloadInputViews];

Expand All @@ -1441,35 +1464,37 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
// them to free up resources and reduce the number of input views in the view
// hierarchy.
//
// This is scheduled on the runloop and delayed by 0.1s so we don't remove the
// The garbage views are decommissioned immediately, but the removeFromSuperview
// call is scheduled on the runloop and delayed by 0.1s so we don't remove the
// text fields immediately (which seems to make the keyboard flicker).
// See: https://github.com/flutter/flutter/issues/64628.
[self performSelector:@selector(collectGarbageInputViews) withObject:nil afterDelay:0.1];
[self cleanUpViewHierarchy:NO clearText:YES delayRemoval:YES];
}

// Updates and shows an input field that is not password related and has no autofill
// hints. This method re-configures and reuses an existing instance of input field
// instead of creating a new one.
// Also updates the current autofill context.
- (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configuration {
// Creates and shows an input field that is not password related and has no autofill
// hints. This method returns a new FlutterTextInputView instance when called, since
// UIKit uses the identity of `UITextInput` instances (or the identity of the input
// views) to decide whether the IME's internal states should be reset. See:
// https://github.com/flutter/flutter/issues/79031 .
- (FlutterTextInputView*)createInputViewWith:(NSDictionary*)configuration {
// It's possible that the configuration of this non-autofillable input view has
// an autofill configuration without hints. If it does, remove it from the context.
NSString* autofillId = autofillIdFromDictionary(configuration);
if (autofillId) {
[_autofillContext removeObjectForKey:autofillId];
}

[_reusableInputView configureWithDictionary:configuration];
[self addToInputParentViewIfNeeded:_reusableInputView];
_reusableInputView.textInputDelegate = _textInputDelegate;
FlutterTextInputView* newView = [[FlutterTextInputView alloc] init];
[newView configureWithDictionary:configuration];
[self addToInputParentViewIfNeeded:newView];
newView.textInputDelegate = _textInputDelegate;

for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
NSString* autofillId = autofillIdFromDictionary(field);
if (autofillId && autofillTypeOf(field) == FlutterAutofillTypeNone) {
[_autofillContext removeObjectForKey:autofillId];
}
}
return _reusableInputView;
return [newView autorelease];
}

- (FlutterTextInputView*)updateAndShowAutofillViews:(NSArray*)fields
Expand Down Expand Up @@ -1547,11 +1572,21 @@ - (UIView*)keyWindow {
return _inputHider.subviews;
}

// Removes every installed input field, unless it's in the current autofill
// context. May remove the active view too if includeActiveView is YES.
// Decommisions (See the "decommision" method on FlutterTextInputView) and removes
// every installed input field, unless it's in the current autofill context.
//
// The active view will be decommisioned and removed from its superview too, if
// includeActiveView is YES.
// When clearText is YES, the text on the input fields will be set to empty before
// they are removed from the view hierarchy, to avoid triggering autofill save.
- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
// If delayRemoval is true, removeFromSuperview will be scheduled on the runloop and
// will be delayed by 0.1s so we don't remove the text fields immediately (which seems
// to make the keyboard flicker).
// See: https://github.com/flutter/flutter/issues/64628.

- (void)cleanUpViewHierarchy:(BOOL)includeActiveView
clearText:(BOOL)clearText
delayRemoval:(BOOL)delayRemoval {
for (UIView* view in self.textInputViews) {
if ([view isKindOfClass:[FlutterTextInputView class]] &&
(includeActiveView || view != _activeView)) {
Expand All @@ -1560,16 +1595,17 @@ - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
if (clearText) {
[inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""];
}
[view removeFromSuperview];
[inputView decommision];
if (delayRemoval) {
[inputView performSelector:@selector(removeFromSuperview) withObject:nil afterDelay:0.1];
} else {
[inputView removeFromSuperview];
}
}
}
}
}

- (void)collectGarbageInputViews {
[self cleanUpViewHierarchy:NO clearText:YES];
}

// Changes the visibility of every FlutterTextInputView currently in the
// view hierarchy.
- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
Expand All @@ -1582,7 +1618,12 @@ - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
}

// Resets the client id of every FlutterTextInputView in the view hierarchy
// to 0. Called when a new text input connection will be established.
// to 0.
// Called before establishing a new text input connection.
// For views in the current autofill context, they need to
// stay in the view hierachy but should not be allowed to
// send messages (other than autofill related ones) to the
// framework.
- (void)resetAllClientIds {
for (UIView* view in self.textInputViews) {
if ([view isKindOfClass:[FlutterTextInputView class]]) {
Expand Down
Loading