diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e02cea6a99b..55411a0aae4 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1230,6 +1230,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien // _lastFormattedUnmodifiedTextEditingValue tracks the last value // that the formatter ran on and is used to prevent double-formatting. TextEditingValue _lastFormattedUnmodifiedTextEditingValue; + // _lastFormattedValue tracks the last post-format value, so that it can be + // reused without rerunning the formatter when the input value is repeated. + TextEditingValue _lastFormattedValue; // _receivedRemoteTextEditingValue is the direct value last passed in // updateEditingValue. This value does not get updated with the formatted // version. @@ -1656,15 +1659,26 @@ class EditableTextState extends State with AutomaticKeepAliveClien // 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; - final bool isRepeat = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text; - if (textChanged && !isRepeat && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { - for (final TextInputFormatter formatter in widget.inputFormatters) + final bool isRepeatText = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text; + final bool isRepeatSelection = value?.selection == _lastFormattedUnmodifiedTextEditingValue?.selection; + // Only format when the text has changed and there are available formatters. + if (!isRepeatText && textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { + for (final TextInputFormatter formatter in widget.inputFormatters) { value = formatter.formatEditUpdate(_value, value); - _value = value; - _updateRemoteEditingValueIfNeeded(); - } else { - _value = value; + } + _lastFormattedValue = value; } + // If the text has changed or the selection has changed, we should update the + // locally stored TextEditingValue to the new one. + if (!isRepeatText || !isRepeatSelection) { + _value = value; + } else if (textChanged && _lastFormattedValue != null) { + _value = _lastFormattedValue; + } + // Always attempt to send the value. If the value has changed, then it will send, + // otherwise, it will short-circuit. + _updateRemoteEditingValueIfNeeded(); + if (textChanged && widget.onChanged != null) widget.onChanged(value.text); _lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue; diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 43e12710323..f9d3d4d2b6c 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -4238,12 +4238,76 @@ void main() { expect(formatter.log, referenceLog); }); + + testWidgets('formatter logic handles repeat filtering', (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); + + expect(formatter.formatCallCount, 0); + state.updateEditingValue(const TextEditingValue(text: '01')); + expect(formatter.formatCallCount, 1); + state.updateEditingValue(const TextEditingValue(text: '012')); + expect(formatter.formatCallCount, 2); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Text change causes reformat + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Selection change does not reformat + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format + expect(formatter.formatCallCount, 3); + state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format + expect(formatter.formatCallCount, 3); + + const List referenceLog = [ + '[1]: , 01', + '[1]: normal aa', + '[2]: aa, 012', + '[2]: normal aaaa', + '[3]: aaaa, 0123', + '[3]: normal aaaaaa', + ]; + + expect(formatter.log, referenceLog); + }); } class MockTextFormatter extends TextInputFormatter { - MockTextFormatter() : _counter = 0, log = []; + MockTextFormatter() : formatCallCount = 0, log = []; - int _counter; + int formatCallCount; List log; @override @@ -4251,8 +4315,8 @@ class MockTextFormatter extends TextInputFormatter { TextEditingValue oldValue, TextEditingValue newValue, ) { - _counter++; - log.add('[$_counter]: ${oldValue.text}, ${newValue.text}'); + formatCallCount++; + log.add('[$formatCallCount]: ${oldValue.text}, ${newValue.text}'); TextEditingValue finalValue; if (newValue.text.length < oldValue.text.length) { finalValue = _handleTextDeletion(oldValue, newValue); @@ -4265,14 +4329,14 @@ class MockTextFormatter extends TextInputFormatter { TextEditingValue _handleTextDeletion( TextEditingValue oldValue, TextEditingValue newValue) { - final String result = 'a' * (_counter - 2); - log.add('[$_counter]: deleting $result'); + final String result = 'a' * (formatCallCount - 2); + log.add('[$formatCallCount]: deleting $result'); return TextEditingValue(text: result); } TextEditingValue _formatText(TextEditingValue value) { - final String result = 'a' * _counter * 2; - log.add('[$_counter]: normal $result'); + final String result = 'a' * formatCallCount * 2; + log.add('[$formatCallCount]: normal $result'); return TextEditingValue(text: result); } }