Repository navigation
Conversation
A backspace that deletes nothing changes no text, so it produces no editing state update and cannot be observed through onChanged, a TextEditingController listener, or a TextInputFormatter. That is the press a PIN or one-time-code field needs in order to move focus to the previous box, and today there is no way to see it. See flutter#191015. The signal exists on both platforms and is discarded. On iOS, UIKit dispatches -deleteBackward even when the view reports no text. Measured on an iPhone 16 running iOS 26.5, against a bare UIView<UITextInput> replicating FlutterTextInputView: three Backspace taps on an empty field produced three dispatches, and a press with the caret at offset 0 in non-empty text produced one more. The engine receives those calls and drops them, because the gates in -deleteBackward leave _selectedTextRange empty, so replaceRange: is never reached and nothing is sent to the framework. Add EditableText.onDeleteBackward, surfaced on TextField and CupertinoTextField, reporting the text that was removed and the empty string when nothing was. It is fed by two legs that never both fire for one press: on iOS a new one-way TextInputClient.didDeleteBackward message, sent from the end of -deleteBackward after the existing logic has run; everywhere else from _DeleteTextAction, which the framework already reaches on an empty field. A bare backspace on iOS never reaches that action, because _iOSDisablingTextShortcuts maps it to DoNothingAndStopPropagationTextIntent. The engine keeps performing the deletion on iOS, including the Thai partial cluster handling and the emoji workaround. The framework only observes, so the asynchronous framework/plugin boundary is unchanged. The notification is sent after the deletion is applied rather than before. The platform to framework leg is inline on the merged platform/UI thread, so a handler invoked from an earlier send would run inside -deleteBackward and could write to a half-updated editor.
|
This pull request targets the Please change the base branch of this pull request to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a gap described in #191015: a backspace that deletes nothing is invisible to app code.
Such a press changes no text, so it produces no editing state update and cannot be observed through
onChanged, aTextEditingControllerlistener, or aTextInputFormatter. It is the press a PIN or one-time-code field needs in order to move focus to the previous box.The signal exists on both platforms and is discarded
iOS. There was no iOS data in #191015, so I measured it. Against a bare
UIView<UITextInput>replicatingFlutterTextInputView— deliberately not aUITextFieldsubclass — on a physical iPhone 16 running iOS 26.5 with the stock keyboard:Reproduced on an iPhone 17 simulator (iOS 26.0). So UIKit dispatches
-deleteBackwardin both cases; the engine receives the call and drops it, because the gates leave_selectedTextRangeempty,replaceRange:is never reached, and that call is the only thing that notifies the framework.The harness is at https://github.com/pdenert/android_keyboard_check/tree/main/tool/ios_delete_backward_probe if you want to reproduce it.
Worth noting: the real condition is "nothing was deleted", not "the field was empty". A non-empty field with the caret at offset 0 is equally silent, and an API scoped to empty fields would miss it.
Android. The framework already reaches
_DeleteTextActionfor a backspace on an empty field; nothing surfaced it to app code.What this adds
EditableText.onDeleteBackward, surfaced onTextFieldandCupertinoTextField, reporting the text that was removed and the empty string when nothing was.Two legs, which never both fire for one press:
TextInputClient.didDeleteBackward, sent from the end of-deleteBackwardafter all existing logic has run._DeleteTextAction. A bare backspace on iOS never reaches it, because_iOSDisablingTextShortcutsmaps it toDoNothingAndStopPropagationTextIntent.The engine keeps performing the deletion on iOS, including the Thai partial-cluster handling and the emoji workaround. The framework only observes, so the asynchronous framework/plugin boundary is unchanged — this does not move deletion into the framework, and so does not depend on making that communication synchronous.
The notification is sent after the deletion is applied, not before. The platform-to-framework leg is inline on the merged platform/UI thread, so a handler invoked from an earlier send would run inside
-deleteBackwardand could write to a half-updated editor.Honest limits, documented on the callback
readOnlyfields.A call means a backward deletion happened; the absence of a call does not mean none did.
Breaking change
Adds a member to
TextInputClient. It has an empty default body, sowith TextInputClientusers are unaffected, but it is source-breaking for anyone declaringimplements TextInputClientorimplements DeltaTextInputClient.Tests
packages/flutter/test/widgets/editable_text_delete_backward_test.dart, 7 tests covering the empty field, the caret at offset 0, the removed-text payload, forward delete,readOnly, the embedder notification, and the iOS no-double-fire invariant.editable_text_test.dart(530) anddefault_text_editing_shortcuts_test.dartpass unchanged.Open questions
onDeleteBackwardfollows theonAppPrivateCommand/onContentInsertedprecedent rather than theTextEditingController.deleteKeyEventsI originally proposed — @justinmc was right to doubt that, since a controller can outlive its field, exist with none, or be shared, andValueNotifiercannot notify when the value is unchanged.TextInputClientaddition acceptable, or would you prefer reusing an existing transport?FlutterTextInputPluginTest.mmcoverage is not included for the same reason. Happy to add it with guidance on running those tests.Pre-launch checklist
///).