diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart index e649a5b226..140625b3c3 100644 --- a/packages/flutter/lib/src/services/text_formatter.dart +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -341,6 +341,15 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { /// counted as a single character, but because it is a combination of two /// Unicode scalar values, '\u{1F44D}\u{1F3FD}', it is counted as two /// characters. + /// + /// ### Composing text behaviors + /// + /// There is no guarantee for the final value before the composing ends. + /// So while the value is composing, the constraint of [maxLength] will be + /// temporary lifted until the composing ends. + /// + /// In addition, if the current value already reached the [maxLength], + /// composing is not allowed. final int? maxLength; /// Truncate the given TextEditingValue to maxLength characters. @@ -367,9 +376,19 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( - TextEditingValue oldValue, // unused. + TextEditingValue oldValue, TextEditingValue newValue, ) { + // Return the new value when the old value has not reached the max + // limit or the old value is composing too. + if (newValue.composing.isValid) { + if (maxLength != null && maxLength! > 0 && + oldValue.text.characters.length == maxLength! && + !oldValue.composing.isValid) { + return oldValue; + } + return newValue; + } if (maxLength != null && maxLength! > 0 && newValue.text.characters.length > maxLength!) { // If already at the maximum and tried to enter even more, keep the old // value. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 34fcbd32f8..4dc8498740 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2100,7 +2100,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien final bool textChanged = _value?.text != value?.text; final bool isRepeat = value == _lastFormattedUnmodifiedTextEditingValue; - if (textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) { + // There's no need to format when starting to compose or when continuing + // an existing composition. + final bool isComposing = value?.composing?.isValid ?? false; + final bool isPreviouslyComposing = _lastFormattedUnmodifiedTextEditingValue?.composing?.isValid ?? false; + + if ((textChanged || (!isComposing && isPreviouslyComposing)) && + widget.inputFormatters != null && + widget.inputFormatters.isNotEmpty) { // Only format when the text has changed and there are available formatters. // Pass through the formatter regardless of repeat status if the input value is // different than the stored value. diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index a5ad790f95..e51d970906 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -5341,6 +5341,40 @@ void main() { expectToAssert(const TextEditingValue(text: 'test', composing: TextRange(start: 1, end: 9)), true); expectToAssert(const TextEditingValue(text: 'test', composing: TextRange(start: -1, end: 9)), false); }); + + // Regression test for https://github.com/flutter/flutter/issues/65374. + testWidgets('Length formatter will not cause crash while the TextEditingValue is composing', (WidgetTester tester) async { + final TextInputFormatter formatter = LengthLimitingTextInputFormatter(5); + final Widget widget = MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + inputFormatters: [formatter], + style: textStyle, + cursorColor: cursorColor, + selectionControls: materialTextSelectionControls, + ), + ); + + await tester.pumpWidget(widget); + + final EditableTextState state = tester.state(find.byType(EditableText)); + state.updateEditingValue(const TextEditingValue(text: '12345')); + expect(state.currentTextEditingValue.composing, TextRange.empty); + state.updateEditingValue(const TextEditingValue(text: '12345', composing: TextRange(start: 2, end: 4))); + expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 4)); + + // Formatter will not update format while the editing value is composing. + state.updateEditingValue(const TextEditingValue(text: '123456', composing: TextRange(start: 2, end: 5))); + expect(state.currentTextEditingValue.text, '123456'); + expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 5)); + + // After composing ends, formatter will update. + state.updateEditingValue(const TextEditingValue(text: '123456')); + expect(state.currentTextEditingValue.text, '12345'); + expect(state.currentTextEditingValue.composing, TextRange.empty); + }); } class MockTextFormatter extends TextInputFormatter { diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index 76da6ffea7..7010b799e6 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -849,4 +849,41 @@ void main() { } expect(() => builder(), throwsAssertionError); }); + + // Regression test for https://github.com/flutter/flutter/issues/65374. + testWidgets('Validate form should return correct validation if the value is composing', (WidgetTester tester) async { + final GlobalKey formKey = GlobalKey(); + String fieldValue; + + final Widget widget = MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: Form( + key: formKey, + child: TextFormField( + maxLength: 5, + onSaved: (String value) { fieldValue = value; }, + validator: (String value) => value.length > 5 ? 'Exceeded' : null, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpWidget(widget); + + final EditableTextState editableText = tester.state(find.byType(EditableText)); + editableText.updateEditingValue(const TextEditingValue(text: '123456', composing: TextRange(start: 2, end: 5))); + expect(editableText.currentTextEditingValue.composing, const TextRange(start: 2, end: 5)); + + formKey.currentState.save(); + expect(fieldValue, '123456'); + expect(formKey.currentState.validate(), isFalse); + }); }