From 83d4d63a716ec8551f0d59f852a40925ddd73d8a Mon Sep 17 00:00:00 2001 From: Gary Qian Date: Thu, 30 Jan 2020 18:10:36 -0800 Subject: [PATCH] Track lastKnownRemoteTextEditingValue separately from received data (#49406) --- .../lib/src/widgets/editable_text.dart | 32 ++++-- .../test/widgets/editable_text_test.dart | 108 ++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 2586a170e7..0998a5bcf9 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1204,7 +1204,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien // TextInputClient implementation: - TextEditingValue _lastKnownRemoteTextEditingValue; + // _lastFormattedUnmodifiedTextEditingValue tracks the last value + // that the formatter ran on and is used to prevent double-formatting. + TextEditingValue _lastFormattedUnmodifiedTextEditingValue; + // _receivedRemoteTextEditingValue is the direct value last passed in + // updateEditingValue. This value does not get updated with the formatted + // version. + TextEditingValue _receivedRemoteTextEditingValue; @override TextEditingValue get currentTextEditingValue => _value; @@ -1216,6 +1222,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (widget.readOnly) { return; } + _receivedRemoteTextEditingValue = value; if (value.text != _value.text) { hideToolbar(); _showCaretOnScreen(); @@ -1224,7 +1231,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien _obscureLatestCharIndex = _value.selection.baseOffset; } } - _lastKnownRemoteTextEditingValue = value; + _formatAndSetValue(value); // To keep the cursor from blinking while typing, we want to restart the @@ -1251,7 +1258,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien break; default: // Finalize editing, but don't give up focus because this keyboard - // action does not imply the user is done inputting information. + // action does not imply the user is done inputting information. _finalizeEditing(false); break; } @@ -1351,9 +1358,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (!_hasInputConnection) return; final TextEditingValue localValue = _value; - if (localValue == _lastKnownRemoteTextEditingValue) + if (localValue == _receivedRemoteTextEditingValue) return; - _lastKnownRemoteTextEditingValue = localValue; _textInputConnection.setEditingState(localValue); } @@ -1414,7 +1420,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien } if (!_hasInputConnection) { final TextEditingValue localValue = _value; - _lastKnownRemoteTextEditingValue = localValue; + _lastFormattedUnmodifiedTextEditingValue = localValue; _textInputConnection = TextInput.attach( this, TextInputConfiguration( @@ -1454,7 +1460,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_hasInputConnection) { _textInputConnection.close(); _textInputConnection = null; - _lastKnownRemoteTextEditingValue = null; + _lastFormattedUnmodifiedTextEditingValue = null; + _receivedRemoteTextEditingValue = null; } } @@ -1472,7 +1479,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_hasInputConnection) { _textInputConnection.connectionClosedReceived(); _textInputConnection = null; - _lastKnownRemoteTextEditingValue = null; + _lastFormattedUnmodifiedTextEditingValue = null; + _receivedRemoteTextEditingValue = null; _finalizeEditing(true); } } @@ -1616,17 +1624,21 @@ class EditableTextState extends State with AutomaticKeepAliveClien } void _formatAndSetValue(TextEditingValue value) { + // Check if the new value is the same as the current local value, or is the same + // as the post-formatting value of the previous pass. final bool textChanged = _value?.text != value?.text; - if (textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { + final bool isRepeat = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text; + if (textChanged && !isRepeat && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { for (final TextInputFormatter formatter in widget.inputFormatters) value = formatter.formatEditUpdate(_value, value); _value = value; _updateRemoteEditingValueIfNeeded(); - } else { + } else if (!isRepeat || !textChanged) { _value = value; } if (textChanged && widget.onChanged != null) widget.onChanged(value.text); + _lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue; } void _onCursorColorTick() { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 439beb7a45..c7a1298c30 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4178,6 +4178,114 @@ void main() { } expect(tester.testTextInput.editingState['text'], 'flutter is the best!...'); }); + + testWidgets('updateEditingValue filters multiple calls from formatter', (WidgetTester tester) async { + final MockTextFormatter formatter = MockTextFormatter(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: FocusScope( + node: focusScopeNode, + autofocus: true, + child: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + maxLines: 1, // Sets text keyboard implicitly. + style: textStyle, + cursorColor: cursorColor, + inputFormatters: [formatter], + ), + ), + ), + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.showKeyboard(find.byType(EditableText)); + controller.text = ''; + await tester.idle(); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + expect(tester.testTextInput.editingState['text'], equals('')); + expect(state.wantKeepAlive, true); + + state.updateEditingValue(const TextEditingValue(text: '')); + state.updateEditingValue(const TextEditingValue(text: 'a')); + state.updateEditingValue(const TextEditingValue(text: 'aa')); + state.updateEditingValue(const TextEditingValue(text: 'aaa')); + state.updateEditingValue(const TextEditingValue(text: 'aa')); + state.updateEditingValue(const TextEditingValue(text: 'aaa')); + state.updateEditingValue(const TextEditingValue(text: 'aaaa')); + state.updateEditingValue(const TextEditingValue(text: 'aa')); + state.updateEditingValue(const TextEditingValue(text: 'aaaaaaa')); + state.updateEditingValue(const TextEditingValue(text: 'aa')); + state.updateEditingValue(const TextEditingValue(text: 'aaaaaaaaa')); + state.updateEditingValue(const TextEditingValue(text: 'aaaaaaaaa')); // Skipped + + const List referenceLog = [ + '[1]: , a', + '[1]: normal aa', + '[2]: aa, aaa', + '[2]: normal aaaa', + '[3]: aaaa, aa', + '[3]: deleting a', + '[4]: a, aaa', + '[4]: normal aaaaaaaa', + '[5]: aaaaaaaa, aaaa', + '[5]: deleting aaa', + '[6]: aaa, aa', + '[6]: deleting aaaa', + '[7]: aaaa, aaaaaaa', + '[7]: normal aaaaaaaaaaaaaa', + '[8]: aaaaaaaaaaaaaa, aa', + '[8]: deleting aaaaaa', + '[9]: aaaaaa, aaaaaaaaa', + '[9]: normal aaaaaaaaaaaaaaaaaa', + ]; + + expect(formatter.log, referenceLog); + }); +} + +class MockTextFormatter extends TextInputFormatter { + MockTextFormatter() : _counter = 0, log = []; + + int _counter; + List log; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + _counter++; + log.add('[$_counter]: ${oldValue.text}, ${newValue.text}'); + TextEditingValue finalValue; + if (newValue.text.length < oldValue.text.length) { + finalValue = _handleTextDeletion(oldValue, newValue); + } else { + finalValue = _formatText(newValue); + } + return finalValue; + } + + + TextEditingValue _handleTextDeletion( + TextEditingValue oldValue, TextEditingValue newValue) { + final String result = 'a' * (_counter - 2); + log.add('[$_counter]: deleting $result'); + return TextEditingValue(text: result); + } + + TextEditingValue _formatText(TextEditingValue value) { + final String result = 'a' * _counter * 2; + log.add('[$_counter]: normal $result'); + return TextEditingValue(text: result); + } } class MockTextSelectionControls extends Mock implements TextSelectionControls {