[ios][engine] Fix keyboard flicker when switching text fields (#182661)

fix https://github.com/flutter/flutter/issues/180842

The issue was caused by https://github.com/flutter/flutter/pull/173598.

When there is a single TextField, the MethodChannel calls are as
follows:

```
handleMethodCall: TextInput.setClient
handleMethodCall: TextInput.setEditableSizeAndTransform
handleMethodCall: TextInput.setMarkedTextRect
handleMethodCall: TextInput.setCaretRect
handleMethodCall: TextInput.setStyle
handleMethodCall: TextInput.setEditingState
handleMethodCall: TextInput.show
handleMethodCall: TextInput.requestAutofill
handleMethodCall: TextInput.clearClient
handleMethodCall: TextInput.hide
```

However, when there are two TextFields, the calls become:


```
handleMethodCall: TextInput.setClient
handleMethodCall: TextInput.setEditableSizeAndTransform
handleMethodCall: TextInput.setMarkedTextRect
handleMethodCall: TextInput.setCaretRect
handleMethodCall: TextInput.setStyle
handleMethodCall: TextInput.setEditingState
handleMethodCall: TextInput.show
handleMethodCall: TextInput.requestAutofill
handleMethodCall: TextInput.clearClient
handleMethodCall: TextInput.setClient
handleMethodCall: TextInput.setEditableSizeAndTransform
handleMethodCall: TextInput.setMarkedTextRect
handleMethodCall: TextInput.setCaretRect
handleMethodCall: TextInput.setStyle
handleMethodCall: TextInput.setEditingState
handleMethodCall: TextInput.show
handleMethodCall: TextInput.requestAutofill
handleMethodCall: TextInput.clearClient
handleMethodCall: TextInput.hide
```

In the multi-field case, `clearTextInputClient` is called before
`setTextInputClient` for the next field. Calling `removeFromSuperview`
during `clearTextInputClient` causes the keyboard to briefly dismiss and
reappear (flicker).

This PR defers the `removeFromSuperview` call from
`clearTextInputClient` to `hideTextInput`, using a pending flag
(`_pendingInputViewRemoval`). The input view is removed only after
`resignFirstResponder` dismisses the keyboard. `showTextInput` resets
the flag to prevent stale flags from causing unintended removal when
switching fields.

For autofill contexts, a separate flag (`_pendingAutofillRemoval`)
defers removal to `triggerAutofillSave`, preserving the existing
autofill save behavior.


https://github.com/user-attachments/assets/751dd6b9-cfc0-401c-a924-c359ae5bc179


https://github.com/user-attachments/assets/cb0831e4-4312-46be-b40e-03df939ad2d5

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
index 2609df3..ec28890 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
@@ -2497,7 +2497,15 @@
 // The current password-autofillable input fields that have yet to be saved.
 @property(nonatomic, readonly)
     NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
-@property(nonatomic, readonly) BOOL pendingInputHiderRemoval;
+// Whether the client disconnected while an autofill context was active.
+// The removeFromSuperview call is delayed until triggerAutofillSave
+// to avoid prematurely ending the autofill session.
+@property(nonatomic, readonly) BOOL pendingAutofillRemoval;
+// Whether the client disconnected without sending a hideText message.
+// This can indicate that the focus is being switched to a different
+// text field and to prevent flickering the removeFromSuperview
+// call should be delayed until hideTextInput.
+@property(nonatomic, readonly) BOOL pendingInputViewRemoval;
 @property(nonatomic, retain) FlutterTextInputView* activeView;
 @property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
 @property(nonatomic, readonly, weak) id<FlutterViewResponder> viewResponder;
@@ -2512,7 +2520,8 @@
 
 @implementation FlutterTextInputPlugin {
   NSTimer* _enableFlutterTextInputViewAccessibilityTimer;
-  BOOL _pendingInputHiderRemoval;
+  BOOL _pendingInputViewRemoval;
+  BOOL _pendingAutofillRemoval;
 }
 
 - (instancetype)initWithDelegate:(id<FlutterTextInputDelegate>)textInputDelegate {
@@ -2834,6 +2843,16 @@
 
 - (void)hideTextInput {
   [_activeView resignFirstResponder];
+
+  // Remove the input view from the view hierarchy after resigning first responder.
+  // This flag is set by clearTextInputClient when autofillContext is empty.
+  // Removing after resignFirstResponder (not during clearTextInputClient) prevents
+  // keyboard flicker when switching between text fields.
+  if (_pendingInputViewRemoval) {
+    [_activeView removeFromSuperview];
+    [_inputHider removeFromSuperview];
+    _pendingInputViewRemoval = NO;
+  }
 }
 
 - (void)triggerAutofillSave:(BOOL)saveEntries {
@@ -2852,10 +2871,11 @@
   [self cleanUpViewHierarchy:YES clearText:!saveEntries delayRemoval:NO];
 
   // Trigger removal of input hider if needed.
-  if (_pendingInputHiderRemoval) {
+  if (_pendingAutofillRemoval) {
     [_activeView removeFromSuperview];
     [_inputHider removeFromSuperview];
-    _pendingInputHiderRemoval = NO;
+    _pendingAutofillRemoval = NO;
+    _pendingInputViewRemoval = NO;
   }
 
   [self addToInputParentViewIfNeeded:_activeView];
@@ -2873,6 +2893,11 @@
 
 - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
   [self resetAllClientIds];
+
+  // Reset pending removal flags set by the previous clearTextInputClient call.
+  _pendingAutofillRemoval = NO;
+  _pendingInputViewRemoval = NO;
+
   // Hide all input views from autofill, only make those in the new configuration visible
   // to autofill.
   [self changeInputViewsAutofillVisibility:NO];
@@ -3112,12 +3137,29 @@
   [self removeEnableFlutterTextInputViewAccessibilityTimer];
   _activeView.accessibilityEnabled = NO;
 
+  // Schedule the removal of the input view and input hider.
+  // The removal is deferred to avoid keyboard flicker when switching between
+  // text fields, where clearTextInputClient is called before the next
+  // setTextInputClient.
+  // See: https://github.com/flutter/flutter/issues/180842
   if (_autofillContext.count == 0) {
-    [_activeView removeFromSuperview];
-    [_inputHider removeFromSuperview];
+    _pendingAutofillRemoval = NO;
+    if (_activeView.isFirstResponder) {
+      // Still first responder: defer removal to hideTextInput,
+      // right after resignFirstResponder dismisses the keyboard.
+      _pendingInputViewRemoval = YES;
+    } else {
+      // Already resigned (hideTextInput was called first):
+      // safe to remove immediately since the keyboard is already dismissed.
+      [_activeView removeFromSuperview];
+      [_inputHider removeFromSuperview];
+      _pendingInputViewRemoval = NO;
+    }
   } else {
-    // If _autofillContext is not empty, triggerAutofillSave will be called to clean up the views.
-    _pendingInputHiderRemoval = YES;
+    // Autofill context exists: removal will be performed in triggerAutofillSave,
+    // which is called by finishAutofillContext to save autofill entries.
+    _pendingAutofillRemoval = YES;
+    _pendingInputViewRemoval = NO;
   }
 }
 
diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
index 72d3a18..57011a3 100644
--- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
+++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
@@ -69,7 +69,8 @@
 @property(nonatomic, readonly) UIView* keyboardView;
 @property(nonatomic, assign) UIView* cachedFirstResponder;
 @property(nonatomic, readonly) CGRect keyboardRect;
-@property(nonatomic, readonly) BOOL pendingInputHiderRemoval;
+@property(nonatomic, readonly) BOOL pendingAutofillRemoval;
+@property(nonatomic, readonly) BOOL pendingInputViewRemoval;
 @property(nonatomic, readonly)
     NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
 
@@ -2723,17 +2724,115 @@
   // Verify initial state.
   [self setClientId:123 configuration:config];
   XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
-  XCTAssertFalse(textInputPlugin.pendingInputHiderRemoval);
+  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
 
   // Retain autofill context.
   [self setClientClear];
   XCTAssertEqual(textInputPlugin.autofillContext.count, 2ul);
-  XCTAssertTrue(textInputPlugin.pendingInputHiderRemoval);
+  XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
 
   // Consume autofill context.
   [self commitAutofillContextAndVerify];
   XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
-  XCTAssertFalse(textInputPlugin.pendingInputHiderRemoval);
+  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
+}
+
+- (void)testSetClientResetsPendingAutofillRemoval {
+  // When autofill context exists and clearClient sets pendingAutofillRemoval,
+  // a subsequent setClient should reset the flag because a new client is
+  // connecting and the deferred removal is no longer needed.
+  NSMutableDictionary* field = self.mutablePasswordTemplateCopy;
+  [field setValue:@{
+    @"uniqueIdentifier" : @"field1",
+    @"hints" : @[ @"password" ],
+    @"editingValue" : @{@"text" : @""}
+  }
+           forKey:@"autofill"];
+  [field setValue:@[ field ] forKey:@"fields"];
+
+  // Set up autofill context.
+  [self setClientId:123 configuration:field];
+  XCTAssertGreaterThan(textInputPlugin.autofillContext.count, 0ul);
+
+  // clearClient with autofill context sets pendingAutofillRemoval.
+  [self setClientClear];
+  XCTAssertTrue(textInputPlugin.pendingAutofillRemoval);
+
+  // A new setClient resets the pending autofill removal flag.
+  [self setClientId:456 configuration:self.mutableTemplateCopy];
+  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
+  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
+}
+
+- (void)testPendingInputViewRemovalAfterClearClient {
+  // When autofillContext is empty and the view is first responder,
+  // clearClient should set pendingInputViewRemoval,
+  // and hideTextInput should consume it.
+  NSDictionary* config = self.mutableTemplateCopy;
+
+  // Verify initial state.
+  [self setClientId:123 configuration:config];
+  XCTAssertEqual(textInputPlugin.autofillContext.count, 0ul);
+  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
+
+  // Stub isFirstResponder to simulate the view being first responder.
+  // In the test environment, becomeFirstResponder does not work.
+  id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
+  OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
+
+  // clearClient with no autofill context sets pendingInputViewRemoval.
+  [self setClientClear];
+  XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
+  XCTAssertFalse(textInputPlugin.pendingAutofillRemoval);
+
+  // hideTextInput consumes the flag and removes the view.
+  [self setTextInputHide];
+  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
+  XCTAssertNil(textInputPlugin.activeView.superview);
+}
+
+- (void)testHideBeforeClearClientRemovesViewImmediately {
+  // When hideTextInput is called before clearTextInputClient,
+  // the view is no longer first responder. clearClient should
+  // remove the view immediately since no keyboard dismiss animation is needed.
+  NSDictionary* config = self.mutableTemplateCopy;
+
+  [self setClientId:123 configuration:config];
+  [self setTextInputShow];
+  XCTAssertNotNil(textInputPlugin.activeView.superview);
+
+  // Hide first: resignFirstResponder, but no removeFromSuperview.
+  [self setTextInputHide];
+  XCTAssertNotNil(textInputPlugin.activeView.superview);
+
+  // clearClient after hide: view is not first responder,
+  // so removeFromSuperview should happen immediately.
+  [self setClientClear];
+  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
+  XCTAssertNil(textInputPlugin.activeView.superview);
+}
+
+- (void)testSetClientResetsPendingInputViewRemoval {
+  // When clearClient sets pendingInputViewRemoval (no autofill, first responder),
+  // a subsequent setClient should reset the flag because a new client is
+  // connecting and the deferred removal is no longer needed.
+  NSDictionary* config = self.mutableTemplateCopy;
+
+  // Field 1: setClient → show → clearClient (sets pendingInputViewRemoval).
+  [self setClientId:123 configuration:config];
+  [self setTextInputShow];
+
+  // Stub isFirstResponder to simulate the view being first responder.
+  // In the test environment, becomeFirstResponder does not work.
+  id mockActiveView = OCMPartialMock(textInputPlugin.activeView);
+  OCMStub([mockActiveView isFirstResponder]).andReturn(YES);
+
+  [self setClientClear];
+  XCTAssertTrue(textInputPlugin.pendingInputViewRemoval);
+
+  // Field 2: setClient resets the stale flag.
+  [self setClientId:456 configuration:config];
+  XCTAssertFalse(textInputPlugin.pendingInputViewRemoval);
 }
 
 - (void)testPasswordAutofillHack {