Skip to content

[iOS] Restore Flutter text focus after native responder recovery - #191781

Merged
auto-submit[bot] merged 6 commits into
flutter:masterfrom
hoyahozz:fix/189051-ios-text-input-focus-sync
Oct 9, 2026
Merged

auto-submit[bot] merged 6 commits into
flutter:masterfrom
hoyahozz:fix/189051-ios-text-input-focus-sync

Conversation

@hoyahozz

@hoyahozz hoyahozz commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #189051

On iOS 26, UIKit resigns the native text responder during a quick app switch and restores it on return. The engine reports the resignation to the framework as onConnectionClosed but never reports the restoration, so the keyboard comes back while every FocusNode stays unfocused and typing goes nowhere.

I filed that issue, together with a sample app that uses nothing but plain TextField widgets.

Root cause

Instrumented unpatched engine, iPhone 17 / iOS 26.6 :

18:54:13.415  resignFirstResponder client=1
18:54:13.420  framework focus cleared (onConnectionClosed)
18:54:13.488  AppLifecycleState.paused
18:54:13.783  becomeFirstResponder client=1      <-- UIKit restores the same view
18:54:14.042  AppLifecycleState.resumed
18:54:14.043  all Flutter text fields focus=false
18:54:14.272  viewInsets.bottom=physical=924.0, logical=308.0

The becomeFirstResponder never reaches Dart. There is no reverse message for "UIKit restored the same view", so only the framework half stays down.

I cannot see UIKit's internals, so I compared behaviour instead. A standalone UIKit app with a single UITextField and no Flutter, built once and installed on both devices, produced this sequence only on iOS 26 :

  • iPhone 15 Pro / iOS 18.1.1 : the field stays first responder across the switch, with no resignation.
  • iPhone 17 / iOS 26.6 : willResignActive → resignFirstResponder → didEndEditing, then willEnterForeground → becomeFirstResponder → didBeginEditing.

Plain UIKit recovers on its own, because didBeginEditing puts the field back into editing. Flutter has no equivalent signal. I cannot say why UIKit does this and Apple does not document it, so I treated it as a behaviour difference observed from iOS 26 onward and fixed the Flutter side of the boundary, which does not depend on the reason.

The fix

FlutterTextInputView now tracks why its responder state changed, and sends the existing TextInputClient.onFocusReceived when UIKit restores it.

  • A non-framework resignFirstResponder on a valid client arms a one-shot restore flag.
  • A real false → true restoration of the same view consumes the flag and sends one notification, so it fires at most once per cycle.
  • TextInput.show, TextInput.hide, TextInput.finishAutofillContext and TextInput.setClient cancel it. showTextInput, hideTextInput and triggerAutofillSave now call becomeFirstResponderFromFramework / resignFirstResponderFromFramework, which is how framework-driven changes are kept out of the classification.
  • Interactive keyboard dismissal never arms it.
  • onConnectionClosed still fires unchanged, so real handoffs to native inputs and system menus are unaffected.
  • No framework changes. TextInput still matches the client id, and EditableTextState.onFocusReceived still checks mounted, _hasFocus and canRequestFocus, so a stale client cannot steal focus.

The guarantee is narrow : if UIKit restores the same responder before the framework declares a new input state, the existing Dart guards get exactly one chance to accept it.

Tests

New Objective-C tests cover :

  • one notification per platform resign → become cycle
  • no notification when either side of the transition came from the framework
  • interactive keyboard dismissal never arming
  • setClient cancelling a pending restoration, for both a new and a repeated id
  • show / hide / finishAutofillContext going through the wrappers
  • the delegate serialising to TextInputClient.onFocusReceived

Validation on device

iPhone 17 / iOS 26.6, using the sample app from the issue. Same app source and same Flutter revision in both runs. Before, the keyboard returns but no field is focused and typing goes nowhere; after, the same field keeps focus across the switch.

Before

before.mov

After

after.mov

Pre-launch Checklist

Notify the framework when UIKit restores a valid Flutter text input view after a platform-driven first-responder resignation.

Framework-driven responder changes and client replacements cancel the pending restoration so stale input clients are not refocused.
@hoyahozz
hoyahozz requested a review from a team as a code owner August 26, 2026 07:09
@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels Aug 26, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a mechanism to track and notify the Flutter framework when the platform's first responder status is restored on iOS. It adds becomeFirstResponderFromFramework and resignFirstResponderFromFramework methods to FlutterTextInputView to distinguish between framework-initiated and platform-initiated responder changes, preventing stale focus state restorations. Corresponding unit tests are added to verify the focus restoration behavior under various scenarios. There are no review comments, and I have no feedback to provide.

@LongCatIsLooong

Copy link
Copy Markdown
Contributor

@hoyahozz sorry for the delay, could you resolve the merge conflicts?

@hoyahozz

Copy link
Copy Markdown
Contributor Author

@LongCatIsLooong Thanks! I’ve resolved the conflict.

@LongCatIsLooong LongCatIsLooong added the CICD Run CI/CD label Sep 23, 2026
Renzo-Olivares
Renzo-Olivares previously approved these changes Oct 2, 2026

@Renzo-Olivares Renzo-Olivares left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM w/ one comment about using the result of TextInputClient.onFocusReceived.

Comment on lines +1334 to +1336
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
didRestoreFirstResponderWithTextInputClient:(int)client {
[self.textInputChannel invokeMethod:@"TextInputClient.onFocusReceived" arguments:@[ @(client) ]];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The framework answers TextInputClient.onFocusReceived with a bool (false when the client is gone, the field can't request focus, or it is already focused), and the web engine already reads it. Here the result is dropped, so when Dart declines, the view stays first responder with the keyboard up and nothing focused. I reproduced that on an iOS 26.3 simulator by setting canRequestFocus = false between the platform resign and the restore.

Suggest reading the result and resigning when it is false, guarded so a client that changed in the meantime is left alone.

Maybe something along the lines of below.

- (void)flutterTextInputView:(FlutterTextInputView*)textInputView
    didRestoreFirstResponderWithTextInputClient:(int)client {
  [self.textInputChannel invokeMethod:@"TextInputClient.onFocusReceived"
                            arguments:@[ @(client) ]
                               result:^(id result) {
                                 if ([result isKindOfClass:[NSNumber class]] && [result boolValue]) {
                                   return;
                                 }
                                 // The framework declined the restored focus. Don't leave the
                                 // keyboard up with no client behind it.
                                 [weakSelf.textInputPlugin dismissRestoredFirstResponder:textInputView client:client];
                               }];
}

with a small textInputPlugin method:

- (void)dismissRestoredFirstResponder:(FlutterTextInputView*)view client:(int)client {
  if (view == _activeView && view.textInputClient == client && view.isFirstResponder) {
    [view resignFirstResponderFromFramework];
  }
}

The resign goes through the "from framework" path so it cannot re-arm the flag, and the resulting onConnectionClosed is ignored by the framework since no connection matches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, addressed in 6faa555. Any reply other than true now resigns through resignFirstResponderFromFramework if the client hasn't changed, with tests for each reply case.

@LongCatIsLooong

Copy link
Copy Markdown
Contributor

Hi @hoyahozz sorry for the ping but could you address the last remaining review comment?

@hoyahozz

hoyahozz commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@LongCatIsLooong Thanks for the ping, and sorry for the delay! I'll push a fix for that comment shortly and let you know.

@LongCatIsLooong LongCatIsLooong added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 7, 2026
@LongCatIsLooong

Copy link
Copy Markdown
Contributor

Unfortunately this didn't make it to the 3.50 release cutoff due to the frequent tree redness lately. I think I'll create a cherry pick request once this is merged so it gets into the 3.50 release.

@auto-submit auto-submit Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 8, 2026
@auto-submit

auto-submit Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

autosubmit label was removed for flutter/flutter/191781, because - The status or check suite Google testing has failed. Please fix the issues identified (or deflake) before re-applying this label.

@LongCatIsLooong LongCatIsLooong added the CICD Run CI/CD label Oct 8, 2026
@justinmc justinmc added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 8, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into flutter:master with commit 0c29588 Oct 9, 2026
26 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 9, 2026
pdenert added a commit to pdenert/flutter that referenced this pull request Oct 12, 2026
When iOS restores first responder to a text input view whose connection it
had reported closed, flutter#191781 now tells the framework, which re-requests
focus from TextInputClient.onFocusReceived. The client then attaches a new
connection, and that connection carries a new client id.

iOS delivers the text picked from an AutoFill sheet such as "Hide My Email"
immediately after restoring first responder, using the client id it already
holds. That delivery arrives before a newly attached connection exists, so
the client id check in TextInput._handleTextInputInvocation discards it and
the field stays empty.

Take the reported connection back instead of letting a new one attach, so
the id the platform is using stays valid and the text is applied.

Fixes flutter#157495
suryaKommana2662 pushed a commit to suryaKommana2662/flutter that referenced this pull request Oct 29, 2026
…tter#191781)

Fixes flutter#189051

On iOS 26, UIKit resigns the native text responder during a quick app
switch and restores it on return. The engine reports the resignation to
the framework as `onConnectionClosed` but never reports the restoration,
so the keyboard comes back while every `FocusNode` stays unfocused and
typing goes nowhere.

I filed that issue, [together with a sample app that uses nothing but
plain `TextField`
widgets.](https://github.com/hoyahozz/ios-keyboard-stuck-after-app-switch)
### Root cause

Instrumented unpatched engine, iPhone 17 / iOS 26.6 :

```text
18:54:13.415  resignFirstResponder client=1
18:54:13.420  framework focus cleared (onConnectionClosed)
18:54:13.488  AppLifecycleState.paused
18:54:13.783  becomeFirstResponder client=1      <-- UIKit restores the same view
18:54:14.042  AppLifecycleState.resumed
18:54:14.043  all Flutter text fields focus=false
18:54:14.272  viewInsets.bottom=physical=924.0, logical=308.0
```

The `becomeFirstResponder` never reaches Dart. There is no reverse
message for "UIKit restored the same view", so only the framework half
stays down.

I cannot see UIKit's internals, so I compared behaviour instead. A
standalone UIKit app with a single `UITextField` and no Flutter, built
once and installed on both devices, produced this sequence only on iOS
26 :

- iPhone 15 Pro / iOS 18.1.1 : the field stays first responder across
the switch, with no resignation.
- iPhone 17 / iOS 26.6 : `willResignActive → resignFirstResponder →
didEndEditing`, then `willEnterForeground → becomeFirstResponder →
didBeginEditing`.

Plain UIKit recovers on its own, because `didBeginEditing` puts the
field back into editing. Flutter has no equivalent signal. I cannot say
why UIKit does this and Apple does not document it, so I treated it as a
behaviour difference observed from **iOS 26** onward and fixed the
Flutter side of the boundary, which does not depend on the reason.

### The fix

`FlutterTextInputView` now tracks *why* its responder state changed, and
sends the existing `TextInputClient.onFocusReceived` when UIKit restores
it.

- A non-framework `resignFirstResponder` on a valid client arms a
one-shot restore flag.
- A real `false → true` restoration of the same view consumes the flag
and sends one notification, so it fires at most once per cycle.
- `TextInput.show`, `TextInput.hide`, `TextInput.finishAutofillContext`
and `TextInput.setClient` cancel it. `showTextInput`, `hideTextInput`
and `triggerAutofillSave` now call `becomeFirstResponderFromFramework` /
`resignFirstResponderFromFramework`, which is how framework-driven
changes are kept out of the classification.
- Interactive keyboard dismissal never arms it.
- `onConnectionClosed` still fires unchanged, so real handoffs to native
inputs and system menus are unaffected.
- No framework changes. `TextInput` still matches the client id, and
`EditableTextState.onFocusReceived` still checks `mounted`, `_hasFocus`
and `canRequestFocus`, so a stale client cannot steal focus.

The guarantee is narrow : *if UIKit restores the same responder before
the framework declares a new input state, the existing Dart guards get
exactly one chance to accept it.*

### Tests

New Objective-C tests cover :

- one notification per platform `resign → become` cycle
- no notification when either side of the transition came from the
framework
- interactive keyboard dismissal never arming
- `setClient` cancelling a pending restoration, for both a new and a
repeated id
- `show` / `hide` / `finishAutofillContext` going through the wrappers
- the delegate serialising to `TextInputClient.onFocusReceived`

### Validation on device

iPhone 17 / iOS 26.6, using the sample app from the issue. Same app
source and same Flutter revision in both runs. Before, the keyboard
returns but no field is focused and typing goes nowhere; after, the same
field keeps focus across the switch.

#### Before


https://github.com/user-attachments/assets/b70e4875-7737-46cb-a3db-738817f5b04c

#### After


https://github.com/user-attachments/assets/045efe24-0cfd-458b-a567-7fe8fd9def73



## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [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.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[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

---------

Co-authored-by: LongCatIsLooong <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS] Keyboard remains visible after quick app switch while TextField no longer has focus

4 participants