diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 06f37f7eef..b5f09888e8 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2236,43 +2236,30 @@ class EditableTextState extends State with AutomaticKeepAliveClien _lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom; } - // This method is often called by platform message handlers that catch and - // send unrecognized exceptions to the engine/platform. Make sure the - // exceptions that user callbacks throw are handled within this method. - void _formatAndSetValue(TextEditingValue newTextEditingValue, 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. + void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) { + // Only apply input formatters if the text has changed (including uncommited + // 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 preformatTextChanged = _value.text != newTextEditingValue.text - || (!_value.composing.isCollapsed && newTextEditingValue.composing.isCollapsed); + final bool textChanged = _value.text != value.text + || (!_value.composing.isCollapsed && value.composing.isCollapsed); + final bool selectionChanged = _value.selection != value.selection; - final List? formatters = widget.inputFormatters; - if (preformatTextChanged && formatters != null && formatters.isNotEmpty) { - try { - for (final TextInputFormatter formatter in formatters) { - newTextEditingValue = formatter.formatEditUpdate(_value, newTextEditingValue); - } - } catch (exception, stack) { - FlutterError.reportError(FlutterErrorDetails( - exception: exception, - stack: stack, - library: 'widgets', - context: ErrorDescription('while applying TextInputFormatters'), - )); - } + if (textChanged) { + value = widget.inputFormatters?.fold( + value, + (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), + ) ?? value; } // Put all optional user callback invocations in a batch edit to prevent // sending multiple `TextInput.updateEditingValue` messages. beginBatchEdit(); - final bool selectionChanged = _value.selection != newTextEditingValue.selection; - final bool textChanged = preformatTextChanged && _value != newTextEditingValue; - _value = newTextEditingValue; + _value = value; // Changes made by the keyboard can sometimes be "out of band" for listening // components, so always send those events, even if we didn't think it // changed. Also, the user long pressing should always send a selection change @@ -2281,11 +2268,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien (userInteraction && (cause == SelectionChangedCause.longPress || cause == SelectionChangedCause.keyboard))) { - _handleSelectionChanged(newTextEditingValue.selection, cause); + _handleSelectionChanged(value.selection, cause); } if (textChanged) { try { - widget.onChanged?.call(newTextEditingValue.text); + widget.onChanged?.call(value.text); } catch (exception, stack) { FlutterError.reportError(FlutterErrorDetails( exception: exception, diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index cb74cd371f..c9266f57e8 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -43,7 +43,6 @@ final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Node'); final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'EditableText Scope Node'); const TextStyle textStyle = TextStyle(); const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); -final TextInputFormatter rejectEverythingFormatter = TextInputFormatter.withFunction((TextEditingValue old, TextEditingValue value) => old); enum HandlePositionInViewport { leftEdge, rightEdge, within, @@ -5706,21 +5705,20 @@ void main() { text: 'I will be modified by the formatter.', selection: controller.selection, )); - expect(log, orderedEquals([ - matchesMethodCall('TextInput.show'), - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf( - containsPair('text', 'Flutter is the best!'), - containsPair('selectionBase', -1), - containsPair('selectionExtent', -1), - containsPair('selectionAffinity', 'TextAffinity.downstream'), - containsPair('selectionIsDirectional', false), - containsPair('composingBase', -1), - containsPair('composingExtent', -1), - ), - ), - ])); + expect(log.length, 1); + MethodCall methodCall = log[0]; + expect( + methodCall, + isMethodCall('TextInput.setEditingState', arguments: { + 'text': 'Flutter is the best!', + 'selectionBase': -1, + 'selectionExtent': -1, + 'selectionAffinity': 'TextAffinity.downstream', + 'selectionIsDirectional': false, + 'composingBase': -1, + 'composingExtent': -1, + }), + ); log.clear(); @@ -5728,21 +5726,21 @@ void main() { setState(() { controller.text = 'I love flutter!'; }); + expect(log.length, 1); + methodCall = log[0]; + expect( + methodCall, + isMethodCall('TextInput.setEditingState', arguments: { + 'text': 'I love flutter!', + 'selectionBase': -1, + 'selectionExtent': -1, + 'selectionAffinity': 'TextAffinity.downstream', + 'selectionIsDirectional': false, + 'composingBase': -1, + 'composingExtent': -1, + }), + ); - expect(log, equals([ - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf( - containsPair('text', 'I love flutter!'), - containsPair('selectionBase', -1), - containsPair('selectionExtent', -1), - containsPair('selectionAffinity', 'TextAffinity.downstream'), - containsPair('selectionIsDirectional', false), - containsPair('composingBase', -1), - containsPair('composingExtent', -1), - ), - ), - ])); log.clear(); // Currently `_receivedRemoteTextEditingValue` equals 'I will be modified by the formatter.', @@ -5750,78 +5748,20 @@ void main() { setState(() { controller.text = 'I will be modified by the formatter.'; }); - expect(log, equals([ - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf( - containsPair('text', 'I will be modified by the formatter.'), - containsPair('selectionBase', -1), - containsPair('selectionExtent', -1), - containsPair('selectionAffinity', 'TextAffinity.downstream'), - containsPair('selectionIsDirectional', false), - containsPair('composingBase', -1), - containsPair('composingExtent', -1), - ), - ), - ])); - }); - - testWidgets('Send text input state to engine when the input formatter rejects everything', (WidgetTester tester) async { - final List log = []; - SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); }); - - final TextEditingController controller = TextEditingController(text: 'initial text'); - - final FocusNode focusNode = FocusNode(); - Widget builder() { - return StatefulBuilder( - builder: (BuildContext context, StateSetter setter) { - return MaterialApp( - home: MediaQuery( - data: const MediaQueryData(devicePixelRatio: 1.0), - child: Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Material( - child: EditableText( - controller: controller, - focusNode: focusNode, - style: textStyle, - cursorColor: Colors.red, - backgroundCursorColor: Colors.red, - keyboardType: TextInputType.multiline, - inputFormatters: [rejectEverythingFormatter], - ), - ), - ), - ), - ), - ); - }, - ); - } - - await tester.pumpWidget(builder()); - await tester.tap(find.byType(EditableText)); - await tester.showKeyboard(find.byType(EditableText)); - await tester.pump(); - - log.clear(); - - final EditableTextState state = tester.firstState(find.byType(EditableText)); - - // The formatter rejects all user inputs, the framework needs to tell the - // engine to restore to the previous editing state. - state.updateEditingValue(TextEditingValue( - text: 'some say kosm', - selection: controller.selection, - )); - expect(log, equals([ - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf(containsPair('text', 'initial text')), - ), - ])); + expect(log.length, 1); + methodCall = log[0]; + expect( + methodCall, + isMethodCall('TextInput.setEditingState', arguments: { + 'text': 'I will be modified by the formatter.', + 'selectionBase': -1, + 'selectionExtent': -1, + 'selectionAffinity': 'TextAffinity.downstream', + 'selectionIsDirectional': false, + 'composingBase': -1, + 'composingExtent': -1, + }), + ); }); testWidgets('Send text input state to engine when the input formatter rejects user input', (WidgetTester tester) async { @@ -5881,30 +5821,26 @@ void main() { text: 'I will be modified by the formatter.', selection: controller.selection, )); - expect(log, equals([ - matchesMethodCall('TextInput.show'), - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf( - containsPair('text', 'Flutter is the best!'), - ), + expect(log.length, 1); + expect(log, contains(matchesMethodCall( + 'TextInput.setEditingState', + args: allOf( + containsPair('text', 'Flutter is the best!'), ), - ])); + ))); log.clear(); state.updateEditingValue(const TextEditingValue( text: 'I will be modified by the formatter.', )); - - expect(log, equals([ - matchesMethodCall( - 'TextInput.setEditingState', - args: allOf( - containsPair('text', 'Flutter is the best!'), - ), + expect(log.length, 1); + expect(log, contains(matchesMethodCall( + 'TextInput.setEditingState', + args: allOf( + containsPair('text', 'Flutter is the best!'), ), - ])); + ))); }); testWidgets('Repeatedly receiving [TextEditingValue] will not trigger a keyboard request', (WidgetTester tester) async { @@ -6972,7 +6908,7 @@ void main() { }); }); - group('state change user callbacks', () { + group('callback errors', () { const String errorText = 'Test EditableText callback error'; testWidgets('onSelectionChanged can throw errors', (WidgetTester tester) async { @@ -7089,89 +7025,6 @@ void main() { expect(error, isFlutterError); expect(error.toString(), contains(errorText)); }); - - testWidgets('TextInputFormatters can throw errors', (WidgetTester tester) async { - final TextInputFormatter alwaysThrowFormatter = TextInputFormatter.withFunction( - (TextEditingValue old, TextEditingValue value) { - throw FlutterError(errorText); - }, - ); - await tester.pumpWidget(MaterialApp( - home: EditableText( - showSelectionHandles: true, - maxLines: 2, - controller: TextEditingController( - text: 'flutter is the best!', - ), - focusNode: FocusNode(), - cursorColor: Colors.red, - backgroundCursorColor: Colors.blue, - style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'), - keyboardType: TextInputType.text, - inputFormatters: [alwaysThrowFormatter], - ), - )); - - await tester.enterText(find.byType(EditableText), '...'); - final dynamic error = tester.takeException(); - expect(error, isFlutterError); - expect(error.toString(), contains(errorText)); - }); - - // Regression test for https://github.com/flutter/flutter/issues/44979. - testWidgets('onChanged callback takes formatter into account', (WidgetTester tester) async { - bool onChangedCalled = false; - await tester.pumpWidget(MaterialApp( - home: EditableText( - showSelectionHandles: true, - maxLines: 2, - controller: TextEditingController( - text: 'flutter is the best!', - ), - focusNode: FocusNode(), - cursorColor: Colors.red, - backgroundCursorColor: Colors.blue, - inputFormatters: [rejectEverythingFormatter], - style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'), - keyboardType: TextInputType.text, - onChanged: (String text) { - onChangedCalled = true; - }, - ), - )); - - // Modify the text and expect to get rejected. - await tester.enterText(find.byType(EditableText), '...'); - expect(onChangedCalled, isFalse); - }); - - testWidgets('onSelectionChanged callback takes formatter into account', (WidgetTester tester) async { - bool onChangedCalled = false; - await tester.pumpWidget(MaterialApp( - home: EditableText( - showSelectionHandles: true, - maxLines: 2, - controller: TextEditingController( - text: 'flutter is the best!', - ), - focusNode: FocusNode(), - cursorColor: Colors.red, - backgroundCursorColor: Colors.blue, - inputFormatters: [rejectEverythingFormatter], - style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'), - keyboardType: TextInputType.text, - onSelectionChanged: (TextSelection selection, SelectionChangedCause? cause) { - onChangedCalled = true; - }, - ), - )); - - final EditableTextState state = tester.state(find.byType(EditableText)); - - // Modify the text and expect an error from onChanged. - state.updateEditingValue(const TextEditingValue(selection: TextSelection.collapsed(offset: 9))); - expect(onChangedCalled, isFalse); - }); }); // Regression test for https://github.com/flutter/flutter/issues/72400.