Repository navigation
[iOS] Restore Flutter text focus after native responder recovery - #191781
auto-submit[bot] merged 6 commits into
Conversation
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.
There was a problem hiding this comment.
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.
|
@hoyahozz sorry for the delay, could you resolve the merge conflicts? |
|
@LongCatIsLooong Thanks! I’ve resolved the conflict. |
Renzo-Olivares
left a comment
There was a problem hiding this comment.
LGTM w/ one comment about using the result of TextInputClient.onFocusReceived.
| - (void)flutterTextInputView:(FlutterTextInputView*)textInputView | ||
| didRestoreFirstResponderWithTextInputClient:(int)client { | ||
| [self.textInputChannel invokeMethod:@"TextInputClient.onFocusReceived" arguments:@[ @(client) ]]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Hi @hoyahozz sorry for the ping but could you address the last remaining review comment? |
|
@LongCatIsLooong Thanks for the ping, and sorry for the delay! I'll push a fix for that comment shortly and let you know. |
|
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. |
|
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. |
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
…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]>
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
onConnectionClosedbut never reports the restoration, so the keyboard comes back while everyFocusNodestays unfocused and typing goes nowhere.I filed that issue, together with a sample app that uses nothing but plain
TextFieldwidgets.Root cause
Instrumented unpatched engine, iPhone 17 / iOS 26.6 :
The
becomeFirstRespondernever 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
UITextFieldand no Flutter, built once and installed on both devices, produced this sequence only on iOS 26 :willResignActive → resignFirstResponder → didEndEditing, thenwillEnterForeground → becomeFirstResponder → didBeginEditing.Plain UIKit recovers on its own, because
didBeginEditingputs 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
FlutterTextInputViewnow tracks why its responder state changed, and sends the existingTextInputClient.onFocusReceivedwhen UIKit restores it.resignFirstResponderon a valid client arms a one-shot restore flag.false → truerestoration of the same view consumes the flag and sends one notification, so it fires at most once per cycle.TextInput.show,TextInput.hide,TextInput.finishAutofillContextandTextInput.setClientcancel it.showTextInput,hideTextInputandtriggerAutofillSavenow callbecomeFirstResponderFromFramework/resignFirstResponderFromFramework, which is how framework-driven changes are kept out of the classification.onConnectionClosedstill fires unchanged, so real handoffs to native inputs and system menus are unaffected.TextInputstill matches the client id, andEditableTextState.onFocusReceivedstill checksmounted,_hasFocusandcanRequestFocus, 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 :
resign → becomecyclesetClientcancelling a pending restoration, for both a new and a repeated idshow/hide/finishAutofillContextgoing through the wrappersTextInputClient.onFocusReceivedValidation 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
///).