mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
squash commits (#68166)
This commit is contained in:
parent
780752e8e3
commit
ce0ec01f64
@ -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: <TextInputFormatter>[
|
||||
/// CompositeTextInputFormatter(
|
||||
/// <TextInputFormatter>[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<TextInputFormatter> newChild = newFormatter.formatters.iterator;
|
||||
final Iterator<TextInputFormatter> 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<TextInputFormatter> 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<TextEditingValue>(
|
||||
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(
|
||||
|
@ -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<TextEditingValue> {
|
||||
/// 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<TextEditingValue> {
|
||||
bool _isSelectionWithinComposingRange(TextSelection selection) {
|
||||
return selection.start >= value.composing.start && selection.end <= value.composing.end;
|
||||
}
|
||||
|
||||
List<TextInputFormatter>? _inputFormatters;
|
||||
void _setInputFormatters(List<TextInputFormatter> 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<TextInputFormatter>? currentValue = _inputFormatters;
|
||||
_inputFormatters = newValue;
|
||||
if (newValue == currentValue || currentValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Iterator<TextInputFormatter> oldFormatters = currentValue.iterator;
|
||||
final Iterator<TextInputFormatter> 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
|
||||
? <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.singleLineFormatter,
|
||||
...inputFormatters ?? const Iterable<TextInputFormatter>.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: <TextInputFormatter>[
|
||||
/// FilteringTextInputFormatter.digitsOnly,
|
||||
/// LengthLimitingTextInputFormatter(10),
|
||||
/// usPhoneNumberFormatter,
|
||||
/// ],
|
||||
/// )
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// {@endtemplate}
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
@ -1550,6 +1663,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clipboardStatus?.addListener(_onChangedClipboardStatus);
|
||||
widget.controller._setInputFormatters(widget.inputFormatters ?? const <TextInputFormatter>[]);
|
||||
widget.controller.addListener(_didChangeTextEditingValue);
|
||||
_focusAttachment = widget.focusNode.attach(context);
|
||||
widget.focusNode.addListener(_handleFocusChanged);
|
||||
@ -1586,11 +1700,11 @@ class EditableTextState extends State<EditableText> 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<EditableText> with AutomaticKeepAliveClien
|
||||
if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) {
|
||||
_clipboardStatus?.update();
|
||||
}
|
||||
|
||||
widget.controller._setInputFormatters(
|
||||
widget.inputFormatters ?? const <TextInputFormatter>[]
|
||||
);
|
||||
endBatchEdit();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -2225,7 +2344,13 @@ class EditableTextState extends State<EditableText> 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<EditableText> with AutomaticKeepAliveClien
|
||||
final bool selectionChanged = _value.selection != value.selection;
|
||||
|
||||
if (textChanged) {
|
||||
value = widget.inputFormatters?.fold<TextEditingValue>(
|
||||
final TextEditingValue formatted = widget.inputFormatters?.fold<TextEditingValue>(
|
||||
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
|
||||
|
@ -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 <Pattern>['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(
|
||||
<TextInputFormatter>[
|
||||
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(
|
||||
<TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
|
||||
LengthLimitingTextInputFormatter(3),
|
||||
]
|
||||
);
|
||||
|
||||
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
|
||||
<TextInputFormatter>[
|
||||
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(
|
||||
<TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
|
||||
LengthLimitingTextInputFormatter(3),
|
||||
]
|
||||
);
|
||||
|
||||
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
|
||||
<TextInputFormatter>[
|
||||
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(
|
||||
<TextInputFormatter>[
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
|
||||
LengthLimitingTextInputFormatter(3),
|
||||
]
|
||||
);
|
||||
|
||||
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
|
||||
<TextInputFormatter>[
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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<TextInputFormatter>? 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: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(null),
|
||||
appendingFormatter,
|
||||
]));
|
||||
|
||||
expect(defaultController.text, contains(previousText + 'a'));
|
||||
previousText = defaultController.text;
|
||||
|
||||
// Change the first formatter.
|
||||
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
|
||||
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: <TextInputFormatter>[
|
||||
LengthLimitingTextInputFormatter(2000),
|
||||
appendingFormatter,
|
||||
]));
|
||||
|
||||
// No reformat needed since the length formatter relaxed its constraint
|
||||
// (1000 -> 2000).
|
||||
expect(defaultController.text, previousText);
|
||||
|
||||
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
|
||||
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: <TextInputFormatter>[
|
||||
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: <TextInputFormatter>[LengthLimitingTextInputFormatter(controller1.text.length)],
|
||||
);
|
||||
final Widget editableText2 = build(
|
||||
controller: controller2,
|
||||
formatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(controller2.text.length)],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Column(children: <Widget>[editableText1, editableText2]),
|
||||
));
|
||||
|
||||
// The 2 input fields swap places. The input formatters should not rerun.
|
||||
await tester.pumpWidget(Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Column(children: <Widget>[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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user