Skip to content
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

Merged
merged 4 commits into from Feb 19, 2021

Conversation

chunhtai
Copy link
Contributor

@chunhtai chunhtai commented Feb 5, 2021

Currently EditableText listens to text editing value changes from its RenderEditable in two ways:

  1. The renderObject.onSelectionChange callback.
  2. The EditableText.textEditingValue setter.

I believe that the original intent of these two methods was:

  1. The onSelectionChange method is only called when a new selection has been created; the EditableText rebuilds the selection overlay when it is called.
  2. 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

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

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

@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label Feb 5, 2021
@google-cla google-cla bot added the cla: yes label Feb 5, 2021
@skia-gold
Copy link

Gold has detected about 17 untriaged digest(s) on patchset 1.
View them at https://flutter-gold.skia.org/cl/github/75541

@chunhtai chunhtai marked this pull request as ready for review February 6, 2021 00:35
@flutter-dashboard
Copy link

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.

@skia-gold
Copy link

Gold has detected about 3 untriaged digest(s) on patchset 2.
View them at https://flutter-gold.skia.org/cl/github/75541

@chunhtai chunhtai changed the title fixes TextInputFormatter gets wrong old value of a selection [Proof of Concept] fixes TextInputFormatter gets wrong old value of a selection Feb 6, 2021
@skia-gold
Copy link

Gold has detected about 3 untriaged digest(s) on patchset 3.
View them at https://flutter-gold.skia.org/cl/github/75541

@skia-gold
Copy link

Gold has detected about 6 untriaged digest(s) on patchset 4.
View them at https://flutter-gold.skia.org/cl/github/75541

Copy link
Contributor

@justinmc justinmc left a 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(
Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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) {
Copy link
Contributor

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 {
Copy link
Contributor

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?

Copy link
Contributor Author

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

@chunhtai
Copy link
Contributor Author

chunhtai commented Feb 8, 2021

@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

@LongCatIsLooong
Copy link
Contributor

@justinmc Is this still needed once we move key events handling out? I'm under the impression that once we're done RenderEditable won't be able to make changes to its TextEditingValue?

@skia-gold
Copy link

Gold has detected about 6 untriaged digest(s) on patchset 10.
View them at https://flutter-gold.skia.org/cl/github/75541

@@ -102,6 +102,7 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe

@override
void onSingleTapUp(TapUpDetails details) {
editableText.hideToolbar();
Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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);
Copy link
Contributor Author

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();
Copy link
Contributor Author

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;
Copy link
Contributor Author

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',
Copy link
Contributor Author

@chunhtai chunhtai Feb 10, 2021

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,
Copy link
Contributor Author

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),
Copy link
Contributor Author

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

@chunhtai chunhtai changed the title [Proof of Concept] fixes TextInputFormatter gets wrong old value of a selection fixes TextInputFormatter gets wrong old value of a selection Feb 10, 2021
@flutter-dashboard flutter-dashboard bot added f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. labels Feb 10, 2021
@chunhtai
Copy link
Contributor Author

Hi @justinmc this pr is ready, can you take another look? thanks!

@chunhtai
Copy link
Contributor Author

a friendly bump

Copy link
Contributor

@LongCatIsLooong LongCatIsLooong left a 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);
Copy link
Contributor

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?

Copy link
Contributor Author

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) ||
Copy link
Contributor

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.

Copy link
Contributor

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)?

Copy link
Contributor Author

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

Copy link
Contributor Author

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

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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) {
Copy link
Contributor

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) =>
Copy link
Contributor

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.

Copy link
Contributor

@justinmc justinmc left a 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();
Copy link
Contributor

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(
Copy link
Contributor

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) {
Copy link
Contributor

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;
}

@chunhtai
Copy link
Contributor Author

Thanks for the review, I will proceed to write breaking change doc for the deprecated callbacks

@chunhtai
Copy link
Contributor Author

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);
Copy link
Contributor

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?

@sgehrman

This comment was marked as abuse.

@chunhtai
Copy link
Contributor Author

chunhtai commented Mar 8, 2021

The stable branch in Flutter 2 has this bug now. Could we get this fixed?

@sgehrman This change was reverted right before the stable release, but it is now relanded. I will see if we can cherry pick this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

TextInputFormatter gets wrong old value of a selection
6 participants