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

Commit 9d6152c

Browse files
Fix iOS text field input keyboard flickering & crash (#20805)
1 parent beb1423 commit 9d6152c

File tree

2 files changed

+140
-33
lines changed

2 files changed

+140
-33
lines changed

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,7 +1121,7 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
11211121

11221122
- (void)showTextInput {
11231123
_activeView.textInputDelegate = _textInputDelegate;
1124-
[self addToKeyWindowIfNeeded:_activeView];
1124+
[self addToInputParentViewIfNeeded:_activeView];
11251125
[_activeView becomeFirstResponder];
11261126
}
11271127

@@ -1143,10 +1143,11 @@ - (void)triggerAutofillSave:(BOOL)saveEntries {
11431143
}
11441144

11451145
[self cleanUpViewHierarchy:YES clearText:!saveEntries];
1146-
[self addToKeyWindowIfNeeded:_activeView];
1146+
[self addToInputParentViewIfNeeded:_activeView];
11471147
}
11481148

11491149
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
1150+
[self resetAllClientIds];
11501151
// Hide all input views from autofill, only make those in the new configuration visible
11511152
// to autofill.
11521153
[self changeInputViewsAutofillVisibility:NO];
@@ -1168,11 +1169,19 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
11681169
break;
11691170
}
11701171

1171-
// Clean up views that should no longer be in the view hierarchy according to the
1172-
// updated autofill context.
1173-
[self cleanUpViewHierarchy:NO clearText:YES];
11741172
[_activeView setTextInputClient:client];
11751173
[_activeView reloadInputViews];
1174+
1175+
// Clean up views that no longer need to be in the view hierarchy, according to
1176+
// the current autofill context. The "garbage" input views are already made
1177+
// invisible to autofill and they can't `becomeFirstResponder`, we only remove
1178+
// them to free up resources and reduce the number of input views in the view
1179+
// hierarchy.
1180+
//
1181+
// This is scheduled on the runloop and delayed by 0.1s so we don't remove the
1182+
// text fields immediately (which seems to make the keyboard flicker).
1183+
// See: https://github.com/flutter/flutter/issues/64628.
1184+
[self performSelector:@selector(collectGarbageInputViews) withObject:nil afterDelay:0.1];
11761185
}
11771186

11781187
// Updates and shows an input field that is not password related and has no autofill
@@ -1188,7 +1197,7 @@ - (FlutterTextInputView*)updateAndShowReusableInputView:(NSDictionary*)configura
11881197
}
11891198

11901199
[_reusableInputView configureWithDictionary:configuration];
1191-
[self addToKeyWindowIfNeeded:_reusableInputView];
1200+
[self addToInputParentViewIfNeeded:_reusableInputView];
11921201
_reusableInputView.textInputDelegate = _textInputDelegate;
11931202

11941203
for (NSDictionary* field in configuration[kAssociatedAutofillFields]) {
@@ -1253,60 +1262,72 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field
12531262
inputView =
12541263
needsPasswordAutofill ? [FlutterSecureTextInputView alloc] : [FlutterTextInputView alloc];
12551264
inputView = [[inputView init] autorelease];
1256-
[self addToKeyWindowIfNeeded:inputView];
1265+
[self addToInputParentViewIfNeeded:inputView];
12571266
}
12581267

12591268
inputView.textInputDelegate = _textInputDelegate;
12601269
[inputView configureWithDictionary:field];
12611270
return inputView;
12621271
}
12631272

1264-
// Removes every installed input field, unless it's in the current autofill
1265-
// context. May remove the active view too if includeActiveView is YES.
1266-
// When clearText is YES, the text on the input fields will be set to empty before
1267-
// they are removed from the view hierarchy, to avoid autofill save .
1268-
- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
1273+
// The UIView to add FlutterTextInputViews to.
1274+
- (UIView*)textInputParentView {
12691275
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
12701276
NSAssert(keyWindow != nullptr,
12711277
@"The application must have a key window since the keyboard client "
12721278
@"must be part of the responder chain to function");
1279+
return keyWindow;
1280+
}
12731281

1274-
for (UIView* view in keyWindow.subviews) {
1282+
// Removes every installed input field, unless it's in the current autofill
1283+
// context. May remove the active view too if includeActiveView is YES.
1284+
// When clearText is YES, the text on the input fields will be set to empty before
1285+
// they are removed from the view hierarchy, to avoid triggering autofill save.
1286+
- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
1287+
for (UIView* view in self.textInputParentView.subviews) {
12751288
if ([view isKindOfClass:[FlutterTextInputView class]] &&
12761289
(includeActiveView || view != _activeView)) {
12771290
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
12781291
if (_autofillContext[inputView.autofillId] != view) {
12791292
if (clearText) {
1280-
inputView.text.string = @"";
1293+
[inputView replaceRangeLocal:NSMakeRange(0, inputView.text.length) withText:@""];
12811294
}
12821295
[view removeFromSuperview];
12831296
}
12841297
}
12851298
}
12861299
}
12871300

1288-
- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
1289-
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
1290-
NSAssert(keyWindow != nullptr,
1291-
@"The application must have a key window since the keyboard client "
1292-
@"must be part of the responder chain to function");
1301+
- (void)collectGarbageInputViews {
1302+
[self cleanUpViewHierarchy:NO clearText:YES];
1303+
}
12931304

1294-
for (UIView* view in keyWindow.subviews) {
1305+
// Changes the visibility of every FlutterTextInputView currently in the
1306+
// view hierarchy.
1307+
- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
1308+
for (UIView* view in self.textInputParentView.subviews) {
12951309
if ([view isKindOfClass:[FlutterTextInputView class]]) {
12961310
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
12971311
inputView.isVisibleToAutofill = newVisibility;
12981312
}
12991313
}
13001314
}
13011315

1302-
- (void)addToKeyWindowIfNeeded:(FlutterTextInputView*)inputView {
1303-
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
1304-
NSAssert(keyWindow != nullptr,
1305-
@"The application must have a key window since the keyboard client "
1306-
@"must be part of the responder chain to function");
1316+
// Resets the client id of every FlutterTextInputView in the view hierarchy
1317+
// to 0. Called when a new text input connection will be established.
1318+
- (void)resetAllClientIds {
1319+
for (UIView* view in self.textInputParentView.subviews) {
1320+
if ([view isKindOfClass:[FlutterTextInputView class]]) {
1321+
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
1322+
[inputView setTextInputClient:0];
1323+
}
1324+
}
1325+
}
13071326

1308-
if (inputView.window != keyWindow) {
1309-
[keyWindow addSubview:inputView];
1327+
- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView {
1328+
UIView* parentView = self.textInputParentView;
1329+
if (inputView.superview != parentView) {
1330+
[parentView addSubview:inputView];
13101331
}
13111332
}
13121333

shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ @interface FlutterTextInputPlugin ()
2727
@property(nonatomic, assign) FlutterTextInputView* activeView;
2828
@property(nonatomic, readonly)
2929
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
30+
31+
- (void)collectGarbageInputViews;
32+
- (UIView*)textInputParentView;
3033
@end
3134

3235
@interface FlutterTextInputPluginTest : XCTestCase
@@ -81,12 +84,7 @@ - (NSMutableDictionary*)mutableTemplateCopy {
8184
}
8285

8386
- (NSArray<FlutterTextInputView*>*)installedInputViews {
84-
UIWindow* keyWindow =
85-
[[[UIApplication sharedApplication] windows]
86-
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"isKeyWindow == YES"]]
87-
.firstObject;
88-
89-
return [keyWindow.subviews
87+
return [textInputPlugin.textInputParentView.subviews
9088
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
9189
[FlutterTextInputView class]]];
9290
}
@@ -407,6 +405,12 @@ - (void)commitAutofillContextAndVerify {
407405
XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
408406
}
409407

408+
- (void)ensureOnlyActiveViewCanBecomeFirstResponder {
409+
for (FlutterTextInputView* inputView in self.installedInputViews) {
410+
XCTAssertEqual(inputView.canBecomeFirstResponder, inputView == textInputPlugin.activeView);
411+
}
412+
}
413+
410414
#pragma mark - Autofill - Tests
411415

412416
- (void)testAutofillContext {
@@ -434,8 +438,11 @@ - (void)testAutofillContext {
434438
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
435439

436440
XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
441+
442+
[textInputPlugin collectGarbageInputViews];
437443
XCTAssertEqual(self.installedInputViews.count, 2);
438444
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
445+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
439446

440447
// The configuration changes.
441448
NSMutableDictionary* field3 = self.mutablePasswordTemplateCopy;
@@ -454,8 +461,11 @@ - (void)testAutofillContext {
454461

455462
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
456463
XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
464+
465+
[textInputPlugin collectGarbageInputViews];
457466
XCTAssertEqual(self.installedInputViews.count, 3);
458467
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
468+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
459469

460470
// Old autofill input fields are still installed and reused.
461471
for (NSString* key in oldContext.allKeys) {
@@ -467,9 +477,12 @@ - (void)testAutofillContext {
467477

468478
oldContext = textInputPlugin.autofillContext;
469479
[self setClientId:124 configuration:config];
480+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
470481

471482
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
472483
XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
484+
485+
[textInputPlugin collectGarbageInputViews];
473486
XCTAssertEqual(self.installedInputViews.count, 4);
474487

475488
// Old autofill input fields are still installed and reused.
@@ -478,6 +491,7 @@ - (void)testAutofillContext {
478491
}
479492
// The active view should change.
480493
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
494+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
481495

482496
// Switch to a similar password field, the previous field should be reused.
483497
oldContext = textInputPlugin.autofillContext;
@@ -486,13 +500,16 @@ - (void)testAutofillContext {
486500
// Reuse the input view instance from the last time.
487501
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
488502
XCTAssertEqual(textInputPlugin.autofillContext.count, 3);
503+
504+
[textInputPlugin collectGarbageInputViews];
489505
XCTAssertEqual(self.installedInputViews.count, 4);
490506

491507
// Old autofill input fields are still installed and reused.
492508
for (NSString* key in oldContext.allKeys) {
493509
XCTAssertEqual(oldContext[key], textInputPlugin.autofillContext[key]);
494510
}
495511
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.autofillContext[@"field1"]);
512+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
496513
}
497514

498515
- (void)testCommitAutofillContext {
@@ -526,21 +543,27 @@ - (void)testCommitAutofillContext {
526543
[self setClientId:123 configuration:config];
527544
XCTAssertEqual(self.viewsVisibleToAutofill.count, 2);
528545
XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
546+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
529547

530548
[self commitAutofillContextAndVerify];
531549
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
550+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
532551

533552
// Install the password field again.
534553
[self setClientId:123 configuration:config];
535554
// Switch to a regular autofill group.
536555
[self setClientId:124 configuration:field3];
537556
XCTAssertEqual(self.viewsVisibleToAutofill.count, 1);
557+
558+
[textInputPlugin collectGarbageInputViews];
538559
XCTAssertEqual(self.installedInputViews.count, 3);
539560
XCTAssertEqual(textInputPlugin.autofillContext.count, 2);
540561
XCTAssertNotEqual(textInputPlugin.textInputView, nil);
562+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
541563

542564
[self commitAutofillContextAndVerify];
543565
XCTAssertNotEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
566+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
544567

545568
// Now switch to an input field that does not autofill.
546569
[self setClientId:125 configuration:self.mutableTemplateCopy];
@@ -549,11 +572,15 @@ - (void)testCommitAutofillContext {
549572
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
550573
// The active view should still be installed so it doesn't get
551574
// deallocated.
575+
576+
[textInputPlugin collectGarbageInputViews];
552577
XCTAssertEqual(self.installedInputViews.count, 1);
553578
XCTAssertEqual(textInputPlugin.autofillContext.count, 0);
579+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
554580

555581
[self commitAutofillContextAndVerify];
556582
XCTAssertEqual(textInputPlugin.textInputView, textInputPlugin.reusableInputView);
583+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
557584
}
558585

559586
- (void)testAutofillInputViews {
@@ -577,6 +604,7 @@ - (void)testAutofillInputViews {
577604
[config setValue:@[ field1, field2 ] forKey:@"fields"];
578605

579606
[self setClientId:123 configuration:config];
607+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
580608

581609
// Find all the FlutterTextInputViews we created.
582610
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
@@ -589,6 +617,7 @@ - (void)testAutofillInputViews {
589617
FlutterTextInputView* inactiveView = inputFields[1];
590618
[inactiveView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 0)]
591619
withText:@"Autofilled!"];
620+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
592621

593622
// Verify behavior.
594623
OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]);
@@ -610,4 +639,61 @@ - (void)testPasswordAutofillHack {
610639
XCTAssertNotEqual([inputView performSelector:@selector(font)], nil);
611640
}
612641

642+
- (void)testClearAutofillContextClearsSelection {
643+
NSMutableDictionary* regularField = self.mutableTemplateCopy;
644+
NSDictionary* editingValue = @{
645+
@"text" : @"REGULAR_TEXT_FIELD",
646+
@"composingBase" : @0,
647+
@"composingExtent" : @3,
648+
@"selectionBase" : @1,
649+
@"selectionExtent" : @4
650+
};
651+
[regularField setValue:@{
652+
@"uniqueIdentifier" : @"field2",
653+
@"hints" : @[ @"hint2" ],
654+
@"editingValue" : editingValue,
655+
}
656+
forKey:@"autofill"];
657+
[regularField addEntriesFromDictionary:editingValue];
658+
[self setClientId:123 configuration:regularField];
659+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
660+
XCTAssertEqual(self.installedInputViews.count, 1);
661+
662+
FlutterTextInputView* oldInputView = self.installedInputViews[0];
663+
XCTAssert([oldInputView.text isEqualToString:@"REGULAR_TEXT_FIELD"]);
664+
FlutterTextRange* selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
665+
XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(1, 3)));
666+
667+
// Replace the original password field with new one. This should remove
668+
// the old password field, but not immediately.
669+
[self setClientId:124 configuration:self.mutablePasswordTemplateCopy];
670+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
671+
672+
XCTAssertEqual(self.installedInputViews.count, 2);
673+
674+
[textInputPlugin collectGarbageInputViews];
675+
XCTAssertEqual(self.installedInputViews.count, 1);
676+
677+
// Verify the old input view is properly cleaned up.
678+
XCTAssert([oldInputView.text isEqualToString:@""]);
679+
selectionRange = (FlutterTextRange*)oldInputView.selectedTextRange;
680+
XCTAssert(NSEqualRanges(selectionRange.range, NSMakeRange(0, 0)));
681+
}
682+
683+
- (void)testGarbageInputViewsAreNotRemovedImmediately {
684+
// Add a password field that should autofill.
685+
[self setClientId:123 configuration:self.mutablePasswordTemplateCopy];
686+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
687+
688+
XCTAssertEqual(self.installedInputViews.count, 1);
689+
// Add an input field that doesn't autofill. This should remove the password
690+
// field, but not immediately.
691+
[self setClientId:124 configuration:self.mutableTemplateCopy];
692+
[self ensureOnlyActiveViewCanBecomeFirstResponder];
693+
694+
XCTAssertEqual(self.installedInputViews.count, 2);
695+
696+
[self commitAutofillContextAndVerify];
697+
}
698+
613699
@end

0 commit comments

Comments
 (0)