New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fixes TextInputFormatter gets wrong old value of a selection #75541
Conversation
Gold has detected about 17 untriaged digest(s) on patchset 1. |
e8a6406
to
13d05f8
Compare
It looks like this pull request may not have tests. Please make sure to add tests before merging. If you need an exemption to this rule, contact Hixie on the #hackers channel in Chat. If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix? Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. |
Gold has detected about 3 untriaged digest(s) on patchset 2. |
Gold has detected about 3 untriaged digest(s) on patchset 3. |
Gold has detected about 6 untriaged digest(s) on patchset 4. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'm on board with this approach assuming tests pass and everything works the same as before. The setter and onSelectionChanged do seem to be redundant and their differences are confusing.
Do we know if anyone besides Flutter itself has been using onSelectionChanged? It seems like there will no longer be a straightforward way to listen for selection changes. Users would have to use updateTextEditingValue and then check if the new value is different, or something like that.
@@ -198,6 +163,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |||
double textScaleFactor = 1.0, | |||
TextSelection? selection, | |||
required ViewportOffset offset, | |||
@Deprecated( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you need another @Deprecated
down on the onSelectionChanged property as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will onSelectionChanged continue to function as it did before until we remove this deprecation? It looks like it may not be called anymore after this PR, or at least the behavior may change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this looks good now 👍
if (onSelectionChanged != null) { | ||
onSelectionChanged!(nextSelection, this, cause); | ||
} | ||
void _setSelectionChange(TextSelection nextSelection, SelectionChangedCause cause) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This slightly bumps against my TextEditingActions PR #75032, where I'm starting to move this _handelKeyEvent code into Shortcuts. It shouldn't be a problem though, I can just update my PR after this one is merged.
@@ -33,37 +33,6 @@ const Radius _kFloatingCaretRadius = Radius.circular(1.0); | |||
/// Used by [RenderEditable.onSelectionChanged]. | |||
typedef SelectionChangedHandler = void Function(TextSelection selection, RenderEditable renderObject, SelectionChangedCause cause); | |||
|
|||
/// Indicates what triggered the change in selected text (including changes to | |||
/// the cursor location). | |||
enum SelectionChangedCause { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a dependency loop or something if you keep this here and import it from text_input.dart?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that will be a layer violation, text_input.dart is in service layer
@justinmc unfortunately I know there are a lot of library out there just copied the entire EditableText, (sometimes also RenderEditable) and makes changes to fit their need, so the changes to the textEditingValue setter will be a big breaking change for them. I think this is something that needed to be done anyway, and i am glad you thought the same. I am curious on the text refactoring project, will it also be a big breaking change too? If so, should we coordinate the merge so we only break them once? Thanks for the feedback, I will update the PR and add more tests |
@justinmc Is this still needed once we move key events handling out? I'm under the impression that once we're done |
f2bb546
to
194df08
Compare
Gold has detected about 6 untriaged digest(s) on patchset 10. |
@@ -102,6 +102,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe | |||
|
|||
@override | |||
void onSingleTapUp(TapUpDetails details) { | |||
editableText.hideToolbar(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have this in Material Textfield but not cupertinotextfield, This is to deal with the case where there are toolbar open and user tap the text outside of the current selection. The expect behavior is the tool bar will be closed and the collapsed selection moved to a new place with the toolbar remaining closed.
The workflow is as the following:
onSingleTapUp(hide toolbar) -> EditableTextOnselectionChange(update toolbar) -> composing changes(hide toolbar)
In theory, if we don't add this hide toolbar, you should see the selection overlay gets moved to the new location with the toolbar open. the toolbar will then be hidden after a frame delay.
This works prior to this pr in a weird way. When user tap and the toolbar will be rebuild with the new selection, but that will only show up after one frame delay. right before the frame end, the selection change again probably due to composing changes. This will hide the toolbar before it builds.
In short, we have a selection overlay built and destroyed in the same frame.
My PR optimizes the selection change that it won't need to rebuild the selection overlay every time. This breaks because the overlay will get updated immediately before the hide toolbar remove the overlay on next frame. Visually, you will see the selection change to the new place with the toolbar still open, and the toolbar will close after one frame.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is also part of the reason why i think we should move the selection overlay out of editable text.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm on board with all of this, I think we're moving in the right direction. Currently, our behavior for showing/hiding the text selection toolbar is incorrect in a bunch of cases already (see #48434 (comment)). Once we've been able to refactor more I hope we can get it all right.
@@ -3475,7 +3475,6 @@ void main() { | |||
from: tester.getTopRight(find.byType(CupertinoApp)), | |||
cause: SelectionChangedCause.tap, | |||
); | |||
expect(state.showToolbar(), true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prior to this pr the entire selection overlay will be rebuilt when the editableText rebuild with different selection.
I feel that is weird. if you have a selection with toolbar and handles on a textfield and you update the texteditingcontroller with different selection, you will see the selection gets moved to a new place with the handles but no toolbar.
After this PR, the toolbar will remain open when the selection moved to the new place.
That means we don't need to call the showtoolbar here in the test.
@@ -119,6 +119,8 @@ void main() { | |||
expect(tester.testTextInput.isVisible, isTrue); | |||
|
|||
tester.testTextInput.hide(); | |||
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); | |||
state.connectionClosed(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test is testing a non realistic situation.
In real app, closing the input connection will cause the textfield to be unfocus, tapping it again, the textfield will regain focus and open the input connection.
This works before because it relies on the bug that the selection does not update correctly in rendereditable. So everytime you tap, you receive a new selection which cause a selection changes and open the keyboard.
@@ -49,6 +49,7 @@ void main() { | |||
}); | |||
|
|||
testWidgets('cursor layout has correct width', (WidgetTester tester) async { | |||
EditableText.debugDeterministicCursor = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pr shifted the blinking timer a bit, i figured the blinking is not necessary for this test
@@ -5357,6 +5357,7 @@ void main() { | |||
'TextInput.setEditingState', | |||
'TextInput.setEditingState', | |||
'TextInput.show', | |||
'TextInput.show', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the additional show is called due to selection change which was not called before, which it should.
text: 'a', | ||
selection: controller.selection, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the selection is not set, it will be set to collapsed at -1, which should cause a selection change. This is not needed before because it previously just ignored the selection change
@@ -6619,6 +6629,7 @@ void main() { | |||
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); | |||
state.updateEditingValue(const TextEditingValue( | |||
text: 'foo composing bar', | |||
selection: TextSelection.collapsed(offset: 4), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
selection needed to be within composing range, otherwise the composing will be reseted by texteditingcontroller. It is not needed before because the it does not fire selection changes prior to this pr
88cafe0
to
4ef68d4
Compare
Hi @justinmc this pr is ready, can you take another look? thanks! |
a friendly bump |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a few questions.
An alternative to this is to use EditableText
's batchEdit
function, storing the old value in beginBatchEdit
and call the user callbacks in endBatchEdit
. But the call paths look really convoluted and thanks for cleaning it up!
/// * [EditableTextState.userUpdateTextEditingValue]: an implementation that | ||
/// applies additional pre-processing to the specified [value], before | ||
/// updating the text editing state. | ||
void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause? cause); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not clear to me when cause
should be null. Could you add that to the documentation?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably should be not nullable, thanks for catching
// if the selection offset didn't change. | ||
if (selectionChanged || | ||
(userInteraction && | ||
((value.selection.baseOffset == 0 && value.selection.extentOffset == 0 && !_hasFocus) || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I saw a similar piece of code in RenderEditable
but I'm not sure I understand what it is for.
when the widget loses focus don't we always set the selection to (-1, -1) in _handleFocusChanged
?
Also _handleSelectionChanged
seems to call the widget.onSelectionChanged
callback which according to its documentation shouldn't be called when the selection isn't changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is it necessary to distinguish TextEditingValue
changes from the virtual keyboard (i.e. userInteraction = false
) and value changes from the raw keyboard events & gestures (i.e. userInteraction = true
)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is for corner case where the textfield is active and user single tap the textfield again to show the selection overlay.
Therefore, we only want to build overlay when it is trigger by the user if the selection is still at 0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yea i should check for selection change before calling onselectionchange
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why the !_hasFocus
if the context menu should only be updated on a focused field?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok this is a series of legacy code and bug make me think this is correct. We want to build the selection overlay when user type keyboard or long press. and we need to want to call the onselectionchange because textfield relies on it to decide whether it really want to show selection handles. (I know it is weird, but again, this why i think we should move selectionoverlay outside..). It has already like this prior to the change, that longpress on a textfield will fire selection change even if selection does not change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I update it to check for long press instead
textSelectionDelegate.userUpdateTextEditingValue(newValue, cause); | ||
} | ||
|
||
void _setSelectionChange(TextSelection nextSelection, SelectionChangedCause cause) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: maybe _setSelection
?
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insertAll(_handles!); | ||
if (_handles == null) { | ||
_handles = <OverlayEntry>[ | ||
OverlayEntry(builder: (BuildContext context) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the style guide says =>
functions should be one-liners.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
Sorry I missed this notification! Thanks for doing this clean up. I'm interested in the batch idea by @LongCatIsLooong above but it should be fine either way.
My text editing refactoring is trying to be minimally breaking, so I think we shouldn't need to coordinate. The first PR is #75032 and it doesn't contain any breaking changes. As I port some of the tap events that affect the stuff in this PR then I might want to break some of the API there, but not yet.
@@ -102,6 +102,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe | |||
|
|||
@override | |||
void onSingleTapUp(TapUpDetails details) { | |||
editableText.hideToolbar(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm on board with all of this, I think we're moving in the right direction. Currently, our behavior for showing/hiding the text selection toolbar is incorrect in a bunch of cases already (see #48434 (comment)). Once we've been able to refactor more I hope we can get it all right.
@@ -198,6 +163,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { | |||
double textScaleFactor = 1.0, | |||
TextSelection? selection, | |||
required ViewportOffset offset, | |||
@Deprecated( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this looks good now 👍
]; | ||
|
||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!.insertAll(_handles!); | ||
if (_handles == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This could be an early return if you prefer.
if (_handles != null) {
return;
}
Thanks for the review, I will proceed to write breaking change doc for the deprecated callbacks |
oh it looks like we don't consider breaking change f no customer tests fail. I guess we should be fine then. |
(userInteraction && | ||
(cause == SelectionChangedCause.longPress || | ||
cause == SelectionChangedCause.keyboard))) { | ||
_handleSelectionChanged(value.selection, cause); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: could you add a TODO since ideally this shouldn't call widget.onSelectionChanged
?
26542bf
to
661324a
Compare
c74943d
to
8868819
Compare
…lutter#75541)" This reverts commit a46139a.
This comment was marked as abuse.
This comment was marked as abuse.
@sgehrman This change was reverted right before the stable release, but it is now relanded. I will see if we can cherry pick this. |
Currently EditableText listens to text editing value changes from its RenderEditable in two ways:
I believe that the original intent of these two methods was:
onSelectionChange
method is only called when a new selection has been created; the EditableText rebuilds the selection overlay when it is called.EditableText.textEditingValue
is called when the selection changes from an old value to a new value, and the EditableText simply updates the selection value in overlay without rebuilding it.RenderEditable becomes confused when the value changes, sometimes, both methods may need to be called... This causes another issue because they will mess up each other.
If renderObject.onSelectionChange is called before EditableText.textEditingValue, the EditableText.textEditingValue will not work properly. The EditableText.textEditingValue will need to notify the inputformatter about value changes, it will send the value to the inputformatter before and after the update. However, the selection has already been updated by the enderObject.onSelectionChange at this point, it does not know what's the selection in the old value. This is the cause of this issue #75502.
if the EditableText.textEditingValue is called before renderObject.onSelectionChange, the renderObject.onSelectionChange will be ignored and will cause the selection overlay to not be built.
Combining all of the above reasons, I created this PR to make editableText only listen to EditableText.textEditingValue (I changes it to updateTextEditingValue because it can't no longer be just a setter), and make renderEditable always call updateTextEditingValue when the selection changes. On top of that. I want to deprecate the renderObject.onSelectionChange because it is not really useful to editableText.
There is currently no test, I will add more test once we are sure this is the path we want to proceed.
fixes #75502.
Pre-launch Checklist
///
).If you need help, consider asking for advice on the #hackers-new channel on Discord.