Prevent committing text from triggering EditableText.onChanged (#112010)
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index c0c7c73..308cbe6 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -2924,19 +2924,20 @@
@pragma('vm:notify-debugger-on-exception')
void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) {
- // Only apply input formatters if the text has changed (including uncommitted
- // text in the composing region), or when the user committed the composing
- // text.
- // Gboard is very persistent in restoring the composing region. Applying
- // input formatters on composing-region-only changes (except clearing the
- // current composing region) is very infinite-loop-prone: the formatters
- // will keep trying to modify the composing region while Gboard will keep
- // trying to restore the original composing region.
- final bool textChanged = _value.text != value.text
- || (!_value.composing.isCollapsed && value.composing.isCollapsed);
- final bool selectionChanged = _value.selection != value.selection;
+ final TextEditingValue oldValue = _value;
+ final bool textChanged = oldValue.text != value.text;
+ final bool textCommitted = !oldValue.composing.isCollapsed && value.composing.isCollapsed;
+ final bool selectionChanged = oldValue.selection != value.selection;
- if (textChanged) {
+ if (textChanged || textCommitted) {
+ // Only apply input formatters if the text has changed (including uncommitted
+ // text in the composing region), or when the user committed the composing
+ // text.
+ // Gboard is very persistent in restoring the composing region. Applying
+ // input formatters on composing-region-only changes (except clearing the
+ // current composing region) is very infinite-loop-prone: the formatters
+ // will keep trying to modify the composing region while Gboard will keep
+ // trying to restore the original composing region.
try {
value = widget.inputFormatters?.fold<TextEditingValue>(
value,
@@ -2970,9 +2971,10 @@
cause == SelectionChangedCause.keyboard))) {
_handleSelectionChanged(_value.selection, cause);
}
- if (textChanged) {
+ final String currentText = _value.text;
+ if (oldValue.text != currentText) {
try {
- widget.onChanged?.call(_value.text);
+ widget.onChanged?.call(currentText);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
@@ -2982,7 +2984,6 @@
));
}
}
-
endBatchEdit();
}
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index c49091d..201a1c1 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -4312,6 +4312,52 @@
expect(render.text!.style!.fontStyle, FontStyle.italic);
});
+ testWidgets('onChanged callback only invoked on text changes', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/111651 .
+ final TextEditingController controller = TextEditingController();
+ int onChangedCount = 0;
+ bool preventInput = false;
+ final TextInputFormatter formatter = TextInputFormatter.withFunction((TextEditingValue oldValue, TextEditingValue newValue) {
+ return preventInput ? oldValue : newValue;
+ });
+
+ final Widget widget = MediaQuery(
+ data: const MediaQueryData(),
+ child: EditableText(
+ controller: controller,
+ backgroundCursorColor: Colors.red,
+ cursorColor: Colors.red,
+ focusNode: FocusNode(),
+ style: textStyle,
+ onChanged: (String newString) { onChangedCount += 1; },
+ inputFormatters: <TextInputFormatter>[formatter],
+ textDirection: TextDirection.ltr,
+ ),
+ );
+ await tester.pumpWidget(widget);
+ final EditableTextState state = tester.firstState(find.byType(EditableText));
+ state.updateEditingValue(
+ const TextEditingValue(text: 'a', composing: TextRange(start: 0, end: 1)),
+ );
+ expect(onChangedCount , 1);
+
+ state.updateEditingValue(
+ const TextEditingValue(text: 'a'),
+ );
+ expect(onChangedCount , 1);
+
+ state.updateEditingValue(
+ const TextEditingValue(text: 'ab'),
+ );
+ expect(onChangedCount , 2);
+
+ preventInput = true;
+ state.updateEditingValue(
+ const TextEditingValue(text: 'abc'),
+ );
+ expect(onChangedCount , 2);
+ });
+
testWidgets('Formatters are skipped if text has not changed', (WidgetTester tester) async {
int called = 0;
final TextInputFormatter formatter = TextInputFormatter.withFunction((TextEditingValue oldValue, TextEditingValue newValue) {