diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart index c10cc3a6995..e739a4d8576 100644 --- a/packages/flutter/lib/src/services/text_formatter.dart +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -11,6 +11,23 @@ import 'package:flutter/foundation.dart'; import 'text_editing.dart'; import 'text_input.dart'; +// Examples can assume: +// late int maxLength; + +/// Function signature expected for creating custom [TextInputFormatter] +/// shorthands via [TextInputFormatter.withFunction]. +typedef TextInputFormatFunction = TextEditingValue Function( + TextEditingValue oldValue, + TextEditingValue newValue, +); + +/// Function signature for creating a custom +/// [CompositeTextInputFormatter.shouldReformat] implementation. +typedef ShouldReformatPredicate = bool Function( + TextInputFormatter oldFormatter, + CompositeTextInputFormatter newFormatter, +); + /// {@template flutter.services.textFormatter.maxLengthEnforcement} /// ### [MaxLengthEnforcement.enforced] versus /// [MaxLengthEnforcement.truncateAfterCompositionEnds] @@ -57,16 +74,36 @@ enum MaxLengthEnforcement { /// A [TextInputFormatter] can be optionally injected into an [EditableText] /// to provide as-you-type validation and formatting of the text being edited. /// -/// Text modification should only be applied when text is being committed by the -/// IME and not on text under composition (i.e., only when +/// An [EditableText] formats its [TextEditingValue] when the user changes the +/// text, or when its [EditableText.inputFormatters] parameter changes. +/// [EditableText] may repetitively apply the same formatter against the input +/// text, therefore a formatter generally should not further modify a +/// [TextEditingValue] if the value has already been formatted by the same +/// formatter. +/// +/// See also the [FilteringTextInputFormatter], a subclass that removes +/// characters that the user tries to enter if they do, or do not, match a given +/// pattern (as applicable). +/// +/// ## Writing a Custom [TextInputFormatter]. +/// +/// To create custom formatters, extend the [TextInputFormatter] class. +/// Generally, text modification should only be applied when text is being +/// committed by the IME and not on text under composition (i.e., only when /// [TextEditingValue.composing] is collapsed). /// -/// See also the [FilteringTextInputFormatter], a subclass that -/// removes characters that the user tries to enter if they do, or do -/// not, match a given pattern (as applicable). +/// It is often eaiser to achieve the desired effects by combining +/// [TextInputFormatter]s, as opposed to creating a dedicated +/// [TextInputFormatter] from the ground up. See [EditableText.inputFormatters] +/// for an example that implements an idempotent US telephone number formatter +/// using composition. /// -/// To create custom formatters, extend the [TextInputFormatter] class and -/// implement the [formatEditUpdate] method. +/// If your input formatter is expensive to run, or the document itself is +/// expensive to format, consider overriding [shouldReformat] to avoid unnessary +/// reformats when the [EditableText] widget rebuilds. If you wish to change the +/// [shouldReformat] strategy used by an existing formatter, consider wrapping +/// it in a [CompositeTextInputFormatter] and providing it with the desired +/// reformat strategy in [CompositeTextInputFormatter.shouldReformatPredicate]. /// /// ## Handling emojis and other complex characters /// {@macro flutter.widgets.EditableText.onChanged} @@ -77,7 +114,11 @@ enum MaxLengthEnforcement { /// * [FilteringTextInputFormatter], a provided formatter for filtering /// characters. abstract class TextInputFormatter { - /// Called when text is being typed or cut/copy/pasted in the [EditableText]. + /// Creates a new [TextInputFormatter]. + const TextInputFormatter(); + + /// Called when text is being typed or cut/copy/pasted in the [EditableText] + /// by the user. /// /// You can override the resulting text based on the previous text value and /// the incoming new text value. @@ -96,14 +137,145 @@ abstract class TextInputFormatter { ) { return _SimpleTextInputFormatter(formatFunction); } + + /// Whether this [TextInputFormatter] can replace another [TextInputFormatter] + /// without triggering a reformat. + /// + /// This method is called by the associated [EditableText] when it rebuilds, + /// to determine whether it can avoid calling [format]. See also + /// [LengthLimitingTextInputFormatter.shouldReformat] for an example that + /// skips reformatting whenever possible. + /// + /// An easy way to determine whether [oldFormatter] can be safely replaced + /// without having to rerun this [TextInputFormatter], is to manually apply + /// [format] to every possible return value of [oldFormatter]'s [format]. If + /// none of the return values changes, it's always safe to return false. + /// + /// The default implementation always returns true. + bool shouldReformat(TextInputFormatter oldFormatter) => true; + + /// Called by [EditableText] when this formatter is added to its + /// [EditableText.inputFormatters]. + /// + /// [EditableText] may repetitively apply this method to the same input text, + /// thus the implementation of this method should not further modify a + /// [TextEditingValue] if the value has already been formatted by the same + /// formatter (by this method or [formatEditUpdate]). + /// + /// If the formatting operation is expensive, try avoid unnecessary [format] + /// calls by returning `false` in [shouldReformat] as much as possible. + TextEditingValue format(TextEditingValue value) => formatEditUpdate(value, value); } -/// Function signature expected for creating custom [TextInputFormatter] -/// shorthands via [TextInputFormatter.withFunction]. -typedef TextInputFormatFunction = TextEditingValue Function( - TextEditingValue oldValue, - TextEditingValue newValue, -); +/// A [TextInputFormatter] that composes one or more child [TextInputFormatter]s. +/// +/// Applying this [CompositeTextInputFormatter] is equivalent to applying all +/// its child [TextInputFormatter]s in the given order. +/// +/// Aside from combining the effects of multiple [TextInputFormatter]s, +/// [CompositeTextInputFormatter] can also be used to create an ad-hoc formatter +/// with a different reformat strategy, without subclassing. +/// +/// {@tool snippet} +/// +/// The following code creates a [LengthLimitingTextInputFormatter] with a +/// varying `maxLength`, but when the `TextField` rebuilds with a smaller +/// `maxLength` value, the new character limit won't be enforced until the user +/// changes the context of the `TextField`. +/// +/// ```dart +/// TextField( +/// inputFormatters: [ +/// CompositeTextInputFormatter( +/// [LengthLimitingTextInputFormatter(maxLength)], +/// shouldReformatPredicate: CompositeTextInputFormatter.neverReformat, +/// ) +/// ] +/// ) +/// +/// ``` +/// {@end-tool} +class CompositeTextInputFormatter implements TextInputFormatter { + /// Creates a [CompositeTextInputFormatter] with a list of child `formatters` + /// and a reformat strategy. + const CompositeTextInputFormatter(this.formatters, { + this.shouldReformatPredicate = anyChildNeedsReformat, + }) : assert(formatters != null), + assert(formatters.length > 0), + assert(shouldReformatPredicate != null); + + /// Only skip reformatting if the [oldFormatter] is also a + /// [CompositeTextInputFormatter] and none of the child input formatters + /// requires reformatting. + /// + /// This is the default [shouldReformat] strategy employed by + /// [CompositeTextInputFormatter]. + static bool anyChildNeedsReformat(TextInputFormatter oldFormatter, CompositeTextInputFormatter newFormatter) { + if (identical(oldFormatter, newFormatter)) + return false; + + if (oldFormatter is! CompositeTextInputFormatter + || newFormatter.formatters.length != oldFormatter.formatters.length) { + return true; + } + + final Iterator newChild = newFormatter.formatters.iterator; + final Iterator oldChild = oldFormatter.formatters.iterator; + while(newChild.moveNext() && oldChild.moveNext()) { + if (newChild.current.shouldReformat(oldChild.current)) + return true; + } + return false; + } + + /// A [ShouldReformatPredicate] that indicates this [CompositeTextInputFormatter] + /// should never perform reformat when replacing another [TextInputFormatter]. + static bool neverReformat(TextInputFormatter oldFormatter, CompositeTextInputFormatter newFormatter) => false; + + /// A [ShouldReformatPredicate] that indicates this [CompositeTextInputFormatter] + /// should always reformat when replacing another [TextInputFormatter]. + static bool alwaysReformat(TextInputFormatter oldFormatter, CompositeTextInputFormatter newFormatter) => true; + + /// The list of child formatters that will be run in the provided order. + /// + /// Must not be null or empty. + final Iterable formatters; + + /// The [shouldReformat] strategy this [CompositeTextInputFormatter] employs. + /// + /// This class provides 3 predefined reformat strategies: + /// * [neverReformat]: the resulting [CompositeTextInputFormatter] never + /// reformats when the [EditableText] it is associated with rebuilds. + /// * [alwaysReformat]: the resulting [CompositeTextInputFormatter] always + /// reformats the [TextEditingValue] when its [EditableText] rebuilds. + /// * [anyChildNeedsReformat]: the resulting [CompositeTextInputFormatter] + /// reformats the [TextEditingValue] when its [EditableText] rebuilds, + /// unless the old formatter is also a [CompositeTextInputFormatter], has + /// the same number of child formatters, and none of the new child input + /// formatters requests reformatting. + /// + /// Defaults to [anyChildNeedsReformat]. + final ShouldReformatPredicate shouldReformatPredicate; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return formatters.fold( + oldValue, + (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(oldValue, newValue), + ); + } + + @override + bool shouldReformat(TextInputFormatter oldFormatter) => shouldReformatPredicate(oldFormatter, this); + + @override + TextEditingValue format(TextEditingValue value) { + return formatters.fold( + value, + (TextEditingValue newValue, TextInputFormatter formatter) => formatter.format(value), + ); + } +} /// Wiring for [TextInputFormatter.withFunction]. class _SimpleTextInputFormatter extends TextInputFormatter { @@ -280,6 +452,14 @@ class FilteringTextInputFormatter extends TextInputFormatter { /// A [TextInputFormatter] that takes in digits `[0-9]` only. static final TextInputFormatter digitsOnly = FilteringTextInputFormatter.allow(RegExp(r'[0-9]')); + + @override + bool shouldReformat(TextInputFormatter oldFormatter) { + return oldFormatter is! FilteringTextInputFormatter + || allow != oldFormatter.allow + || filterPattern != oldFormatter.filterPattern + || replacementString != oldFormatter.replacementString; + } } /// Old name for [FilteringTextInputFormatter.deny]. @@ -526,6 +706,23 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { return truncate(newValue, maxLength); } } + + @override + bool shouldReformat(TextInputFormatter oldFormatter) { + // With maxLength == null or -1, this formatter is basically an identity + // function and imposes no constraints on the user input. Thus it can be + // used to update an arbitrary formatter without re-formatting. + final int? maxLength = this.maxLength; + if (maxLength == null || maxLength == -1) + return false; + + if (oldFormatter is! LengthLimitingTextInputFormatter) + return true; + + final int? maxLengthOld = oldFormatter.maxLength; + return (maxLengthOld == null || maxLengthOld == -1) + || maxLength < maxLengthOld; + } } TextEditingValue _selectionAwareTextManipulation( diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 2dc8a620fac..dc228de163d 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -34,6 +34,9 @@ import 'ticker_provider.dart'; export 'package:flutter/rendering.dart' show SelectionChangedCause; export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType; +// Examples can assume: +// late TextInputFormatter usPhoneNumberFormatter; + /// Signature for the callback that reports when the user changes the selection /// (including the cursor location). typedef SelectionChangedCallback = void Function(TextSelection selection, SelectionChangedCause? cause); @@ -225,7 +228,7 @@ class TextEditingController extends ValueNotifier { /// change the controller's [value]. /// /// If the new selection if of non-zero length, or is outside the composing - /// range, the composing composing range is cleared. + /// range, the composing range is cleared. set selection(TextSelection newSelection) { if (!isSelectionWithinTextBounds(newSelection)) throw FlutterError('invalid text selection: $newSelection'); @@ -272,6 +275,49 @@ class TextEditingController extends ValueNotifier { bool _isSelectionWithinComposingRange(TextSelection selection) { return selection.start >= value.composing.start && selection.end <= value.composing.end; } + + List? _inputFormatters; + void _setInputFormatters(List newValue) { + // The setter does not take null values: if currentValue is null that means + // this is the first formatter list ever set, and we should not reformat. + final List? currentValue = _inputFormatters; + _inputFormatters = newValue; + if (newValue == currentValue || currentValue == null) { + return; + } + + final Iterator oldFormatters = currentValue.iterator; + final Iterator newFormatters = newValue.iterator; + + // Determining how many new input formatters need to be rerun: + // + // * The entire `newValue` list needs to be rerun if it has less formatters + // than the current list, or any of the new input formatter requests + // reformatting. + // * Otherwise, only apply the new input formatters whose index is larger + // than newValue.length. + bool needsReformat = currentValue.length > newValue.length; + while (!needsReformat && oldFormatters.moveNext() && newFormatters.moveNext()) { + if (newFormatters.current.shouldReformat(oldFormatters.current)) { + needsReformat = true; + } + } + + TextEditingValue formatted = value; + + if (needsReformat || oldFormatters.moveNext()) { + formatted = newValue.fold( + formatted, + (TextEditingValue v, TextInputFormatter formatter) => formatter.format(v), + ); + } else { + while (newFormatters.moveNext()) { + formatted = newFormatters.current.format(formatted); + } + } + + value = formatted; + } } /// Toolbar configuration for [EditableText]. @@ -525,7 +571,7 @@ class EditableText extends StatefulWidget { inputFormatters = maxLines == 1 ? [ FilteringTextInputFormatter.singleLineFormatter, - ...inputFormatters ?? const Iterable.empty(), + ...?inputFormatters, ] : inputFormatters, showCursor = showCursor ?? !readOnly, @@ -1058,9 +1104,76 @@ class EditableText extends StatefulWidget { /// {@template flutter.widgets.editableText.inputFormatters} /// Optional input validation and formatting overrides. /// - /// Formatters are run in the provided order when the text input changes. When - /// this parameter changes, the new formatters will not be applied until the - /// next time the user inserts or deletes text. + /// Formatters are run in the provided order when the user changes the text + /// contained in the widget. They're not applied when the changes are + /// selection only, or not initiated by the user. + /// + /// When this widget rebuilds, each input formatter in the new widget's + /// [inputFormatters] list checks the configuration of the input formatter + /// from the same location in the old [inputFormatters], to determine if the + /// new formatters need to be re-applied to the current [TextEditingValue] of + /// this widget. + /// + /// {@tool snippet} + /// + /// The following code uses a combination of 2 [TextInputFormatter]s and a + /// `UsPhoneNumberFormatter` (which simply adds parentheses and hypens), to + /// turn user input into a valid United States telephone number (for example, + /// (123)456-7890). + /// + /// The combined effect of the 3 formatters is idempotent, meaning applying + /// them together to an already formatted value is a no-op. The + /// `UsPhoneNumberFormatter` is not idempotent, thus should not be used by + /// itself. + /// + /// ```dart + /// class UsPhoneNumberFormatter extends TextInputFormatter { + /// const UsPhoneNumberFormatter(); + /// + /// @override + /// TextEditingValue format(TextEditingValue value) { + /// final int inputLength = value.text.length; + /// if (inputLength <= 3) + /// return value; + /// + /// final StringBuffer newText = StringBuffer(); + /// + /// newText.write('('); + /// newText.write(value.text.substring(0, 3)); + /// newText.write(')'); + /// newText.write(value.text.substring(3, math.min(6, inputLength))); + /// + /// if (inputLength > 6) { + /// newText.write('-'); + /// newText.write(value.text.substring(6)); + /// } + /// + /// final int selectionOffset = value.selection.end <= 3 ? 1 : value.selection.end <= 6 ? 2 : 3; + /// return TextEditingValue( + /// text: newText.toString(), + /// selection: TextSelection.collapsed(offset: value.selection.end + selectionOffset), + /// ); + /// } + /// + /// @override + /// TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) => format(newValue); + /// + /// @override + /// bool shouldReformat(TextInputFormatter oldFormatter) => oldFormatter is! UsPhoneNumberFormatter; + /// } + /// ``` + /// + /// ```dart + /// TextField( + /// inputFormatters: [ + /// FilteringTextInputFormatter.digitsOnly, + /// LengthLimitingTextInputFormatter(10), + /// usPhoneNumberFormatter, + /// ], + /// ) + /// ``` + /// {@end-tool} + /// /// {@endtemplate} final List? inputFormatters; @@ -1550,6 +1663,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien void initState() { super.initState(); _clipboardStatus?.addListener(_onChangedClipboardStatus); + widget.controller._setInputFormatters(widget.inputFormatters ?? const []); widget.controller.addListener(_didChangeTextEditingValue); _focusAttachment = widget.focusNode.attach(context); widget.focusNode.addListener(_handleFocusChanged); @@ -1586,11 +1700,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void didUpdateWidget(EditableText oldWidget) { + beginBatchEdit(); super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.removeListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue); - _updateRemoteEditingValueIfNeeded(); } if (widget.controller.selection != oldWidget.controller.selection) { _selectionOverlay?.update(_value); @@ -1636,6 +1750,11 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) { _clipboardStatus?.update(); } + + widget.controller._setInputFormatters( + widget.inputFormatters ?? const [] + ); + endBatchEdit(); } @override @@ -2225,7 +2344,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien _lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom; } - late final _WhitespaceDirectionalityFormatter _whitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection); + _WhitespaceDirectionalityFormatter? _lastUsedWhitespaceFormatter; + _WhitespaceDirectionalityFormatter get _whitespaceFormatter { + final _WhitespaceDirectionalityFormatter? lastUsed = _lastUsedWhitespaceFormatter; + if (lastUsed != null && lastUsed._baseDirection == _textDirection) + return lastUsed; + return _lastUsedWhitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection); + } void _formatAndSetValue(TextEditingValue value) { // Only apply input formatters if the text has changed (including uncommited @@ -2241,18 +2366,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien final bool selectionChanged = _value.selection != value.selection; if (textChanged) { - value = widget.inputFormatters?.fold( + final TextEditingValue formatted = widget.inputFormatters?.fold( value, (TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue), ) ?? value; - // Always pass the text through the whitespace directionality formatter to // maintain expected behavior with carets on trailing whitespace. - // TODO(LongCatIsLooong): The if statement here is for retaining the - // previous behavior. The input formatter logic will be updated in an - // upcoming PR. - if (widget.inputFormatters?.isNotEmpty ?? false) - value = _whitespaceFormatter.formatEditUpdate(_value, value); + value = _whitespaceFormatter.formatEditUpdate(_value, formatted); } // Put all optional user callback invocations in a batch edit to prevent diff --git a/packages/flutter/test/services/text_formatter_test.dart b/packages/flutter/test/services/text_formatter_test.dart index b5cb9dd37ab..267199fb73b 100644 --- a/packages/flutter/test/services/text_formatter_test.dart +++ b/packages/flutter/test/services/text_formatter_test.dart @@ -628,4 +628,235 @@ void main() { // cursor must be now at fourth position (right after the number 9) expect(formatted.selection.baseOffset, equals(4)); }); + + group('provided formatters implement shouldReformat correctly', () { + test('length limiting formatter', () { + expect( + LengthLimitingTextInputFormatter(-1).shouldReformat(LengthLimitingTextInputFormatter(null)), + isFalse, + ); + + expect( + LengthLimitingTextInputFormatter(null).shouldReformat(LengthLimitingTextInputFormatter(-1)), + isFalse, + ); + + expect( + LengthLimitingTextInputFormatter(null).shouldReformat(LengthLimitingTextInputFormatter(null)), + isFalse, + ); + + expect( + LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(3)), + isFalse, + ); + + // We're relaxing the length constraint. No reformatting needed. + expect( + LengthLimitingTextInputFormatter(-1).shouldReformat(LengthLimitingTextInputFormatter(3)), + isFalse, + ); + + // We're relaxing the length constraint. No reformatting needed. + expect( + LengthLimitingTextInputFormatter(4).shouldReformat(LengthLimitingTextInputFormatter(3)), + isFalse, + ); + + expect( + LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(4)), + isTrue, + ); + + expect( + LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(null)), + isTrue, + ); + + expect( + LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(-1)), + isTrue, + ); + }); + + test('FliteringTextInputFormatter', () { + expect( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b'), + ), + isFalse, + ); + + expect( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat( + FilteringTextInputFormatter('a', allow: true, replacementString: 'c'), + ), + isTrue, + ); + + expect( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat( + FilteringTextInputFormatter('a', allow: false, replacementString: 'b'), + ), + isTrue, + ); + + expect( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat( + FilteringTextInputFormatter('c', allow: true, replacementString: 'b'), + ), + isTrue, + ); + + expect( + FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat( + FilteringTextInputFormatter('c', allow: true), + ), + isTrue, + ); + }); + }); + + group('provided formatters do not further modify a formatted value', () { + // Framework-provided TextInputFormatters must be idempotent in order to be + // used alone. + void verifyFormatterIdempotency( + TextInputFormatter formatter, + TextEditingValue input, + ) { + final TextEditingValue formatted = formatter.format(input); + expect(formatter.format(formatted), formatted); + } + + setUp(() { + // a1b(2c3 + // d4)e5f6 + // where the parentheses are the selection range. + testNewValue = const TextEditingValue( + text: 'a1b2c3\nd4e5f6', + selection: TextSelection( + baseOffset: 3, + extentOffset: 9, + ), + ); + }); + + test('FliteringTextInputFormatter with replacementString', () { + const TextEditingValue selectedIntoTheWoods = TextEditingValue( + text: 'Into the Woods', + selection: TextSelection(baseOffset: 11, extentOffset: 14), + ); + + for (final Pattern p in ['o', RegExp('o+')]) { + verifyFormatterIdempotency( + FilteringTextInputFormatter(p, allow: true, replacementString: '*'), + selectedIntoTheWoods, + ); + verifyFormatterIdempotency( + FilteringTextInputFormatter(p, allow: false, replacementString: '*'), + selectedIntoTheWoods, + ); + } + }); + + test('single line formatter', () { + verifyFormatterIdempotency( + FilteringTextInputFormatter.singleLineFormatter, + testNewValue, + ); + }); + + test('digits only formatter', () { + verifyFormatterIdempotency( + FilteringTextInputFormatter.digitsOnly, + testNewValue, + ); + }); + + test('length limiting formatter', () { + verifyFormatterIdempotency( + LengthLimitingTextInputFormatter(5), + testNewValue, + ); + }); + }); + + group('CompositeTextInputFormatter', () { + test('combine effects, in provided order', () { + final CompositeTextInputFormatter formatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(3), + ] + ); + + expect(formatter.format(const TextEditingValue(text: 'aab')).text, 'aab'); + expect( + formatter.formatEditUpdate(const TextEditingValue(text: 'aaa'), const TextEditingValue(text: 'aab')).text, + 'aaa', + ); + }); + + test('anyChildNeedsReformat', () { + final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(3), + ] + ); + + final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(1), + ] + ); + + expect(newFormatter.shouldReformat(newFormatter), isFalse); + expect(oldFormatter.shouldReformat(oldFormatter), isFalse); + expect(newFormatter.shouldReformat(oldFormatter), isTrue); + }); + + test('neverReformat', () { + final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(3), + ] + ); + + final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(1), + ], + shouldReformatPredicate: CompositeTextInputFormatter.neverReformat, + ); + + expect(newFormatter.shouldReformat(newFormatter), isFalse); + expect(oldFormatter.shouldReformat(oldFormatter), isFalse); + expect(newFormatter.shouldReformat(oldFormatter), isFalse); + }); + + test('alwaysReformat', () { + final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(3), + ] + ); + + final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter( + [ + FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'), + LengthLimitingTextInputFormatter(999), + ], + shouldReformatPredicate: CompositeTextInputFormatter.alwaysReformat, + ); + + expect(newFormatter.shouldReformat(newFormatter), isTrue); + expect(oldFormatter.shouldReformat(oldFormatter), isFalse); + expect(newFormatter.shouldReformat(oldFormatter), isTrue); + }); + }); } diff --git a/packages/flutter/test/widgets/editable_text_didUpdateWidget_test.dart b/packages/flutter/test/widgets/editable_text_didUpdateWidget_test.dart new file mode 100644 index 00000000000..ae0b20ab3e3 --- /dev/null +++ b/packages/flutter/test/widgets/editable_text_didUpdateWidget_test.dart @@ -0,0 +1,149 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter/services.dart'; + +void main() { + final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Node'); + const TextStyle textStyle = TextStyle(); + const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + const Color backgroundColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); + late TextEditingController defaultController; + + group('didUpdateWidget', () { + final _AppendingFormatter appendingFormatter = _AppendingFormatter(); + + Widget build({ + TextDirection textDirection = TextDirection.ltr, + List? formatters, + TextEditingController? controller, + }) { + return MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: textDirection, + child: EditableText( + backgroundCursorColor: backgroundColor, + controller: controller ?? defaultController, + maxLines: null, // Remove the builtin newline formatter. + focusNode: focusNode, + style: textStyle, + cursorColor: cursorColor, + inputFormatters: formatters, + ), + ), + ); + } + + testWidgets('EditableText only reformats when needed', (WidgetTester tester) async { + appendingFormatter.needsReformat = false; + defaultController = TextEditingController(text: 'initialText'); + String previousText = defaultController.text; + + // Initial build, do not apply formatters. + await tester.pumpWidget(build()); + expect(defaultController.text, previousText); + + await tester.pumpWidget(build(formatters: [ + LengthLimitingTextInputFormatter(null), + appendingFormatter, + ])); + + expect(defaultController.text, contains(previousText + 'a')); + previousText = defaultController.text; + + // Change the first formatter. + await tester.pumpWidget(build(formatters: [ + LengthLimitingTextInputFormatter(1000), + appendingFormatter, + ])); + + // Reformat since the length formatter changed and it becomes more + // strict (null -> 1000). + expect(defaultController.text, contains(previousText + 'a')); + previousText = defaultController.text; + + await tester.pumpWidget(build(formatters: [ + LengthLimitingTextInputFormatter(2000), + appendingFormatter, + ])); + + // No reformat needed since the length formatter relaxed its constraint + // (1000 -> 2000). + expect(defaultController.text, previousText); + + await tester.pumpWidget(build(formatters: [ + appendingFormatter, + ])); + + // Reformat since we reduced the number of new formatters. + expect(defaultController.text, previousText + 'a'); + previousText = defaultController.text; + + // Now the the appending formatter always requests a reformat when + // didUpdateWidget is called. + appendingFormatter.needsReformat = true; + + await tester.pumpWidget(build(formatters: [ + appendingFormatter, + ])); + + // Reformat since appendingFormatter now always requests a rerun. + expect(defaultController.text, contains(previousText + 'a')); + previousText = defaultController.text; + }); + + testWidgets( + 'Changing the controller along with the formatter does not reformat', + (WidgetTester tester) async { + // This test verifies that the `shouldReformat` predicate is run against + // the previous formatter associated with the *TextEditingController*, + // instead of the one associated with the widget, to avoid unnecessary + // rebuilds. + final TextEditingController controller1 = TextEditingController(text: 'shorttxt'); + final TextEditingController controller2 = TextEditingController(text: 'looooong text'); + + final Widget editableText1 = build( + controller: controller1, + formatters: [LengthLimitingTextInputFormatter(controller1.text.length)], + ); + final Widget editableText2 = build( + controller: controller2, + formatters: [LengthLimitingTextInputFormatter(controller2.text.length)], + ); + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Column(children: [editableText1, editableText2]), + )); + + // The 2 input fields swap places. The input formatters should not rerun. + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: Column(children: [editableText2, editableText1]), + )); + + expect(controller1.text, 'shorttxt'); + expect(controller2.text, 'looooong text'); + }); +}); + +} + + +// A TextInputFormatter that appends 'a' to the current editing value every time +// it runs. +class _AppendingFormatter extends TextInputFormatter { + bool needsReformat = true; + + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return newValue.copyWith(text: newValue.text + 'a'); + } + + @override + bool shouldReformat(TextInputFormatter oldFormatter) => needsReformat; +}