mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Reland "Make FilteringTextInputFormatter's filtering Selection/Composing Region agnostic" #89327 (#90211)
This commit is contained in:
parent
ab51a0260d
commit
7684f8b7c5
@ -121,6 +121,94 @@ class _SimpleTextInputFormatter extends TextInputFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A mutable, half-open range [`base`, `extent`) within a string.
|
||||||
|
class _MutableTextRange {
|
||||||
|
_MutableTextRange(this.base, this.extent);
|
||||||
|
|
||||||
|
static _MutableTextRange? fromComposingRange(TextRange range) {
|
||||||
|
return range.isValid && !range.isCollapsed
|
||||||
|
? _MutableTextRange(range.start, range.end)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _MutableTextRange? fromTextSelection(TextSelection selection) {
|
||||||
|
return selection.isValid
|
||||||
|
? _MutableTextRange(selection.baseOffset, selection.extentOffset)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start index of the range, inclusive.
|
||||||
|
///
|
||||||
|
/// The value of [base] should always be greater than or equal to 0, and can
|
||||||
|
/// be larger than, smaller than, or equal to [extent].
|
||||||
|
int base;
|
||||||
|
|
||||||
|
/// The end index of the range, exclusive.
|
||||||
|
///
|
||||||
|
/// The value of [extent] should always be greater than or equal to 0, and can
|
||||||
|
/// be larger than, smaller than, or equal to [base].
|
||||||
|
int extent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The intermediate state of a [FilteringTextInputFormatter] when it's
|
||||||
|
// formatting a new user input.
|
||||||
|
class _TextEditingValueAccumulator {
|
||||||
|
_TextEditingValueAccumulator(this.inputValue)
|
||||||
|
: selection = _MutableTextRange.fromTextSelection(inputValue.selection),
|
||||||
|
composingRegion = _MutableTextRange.fromComposingRange(inputValue.composing);
|
||||||
|
|
||||||
|
// The original string that was sent to the [FilteringTextInputFormatter] as
|
||||||
|
// input.
|
||||||
|
final TextEditingValue inputValue;
|
||||||
|
|
||||||
|
/// The [StringBuffer] that contains the string which has already been
|
||||||
|
/// formatted.
|
||||||
|
///
|
||||||
|
/// In a [FilteringTextInputFormatter], typically the replacement string,
|
||||||
|
/// instead of the original string within the given range, is written to this
|
||||||
|
/// [StringBuffer].
|
||||||
|
final StringBuffer stringBuffer = StringBuffer();
|
||||||
|
|
||||||
|
/// The updated selection, as well as the original selection from the input
|
||||||
|
/// [TextEditingValue] of the [FilteringTextInputFormatter].
|
||||||
|
///
|
||||||
|
/// This parameter will be null if the input [TextEditingValue.selection] is
|
||||||
|
/// invalid.
|
||||||
|
final _MutableTextRange? selection;
|
||||||
|
|
||||||
|
/// The updated composing region, as well as the original composing region
|
||||||
|
/// from the input [TextEditingValue] of the [FilteringTextInputFormatter].
|
||||||
|
///
|
||||||
|
/// This parameter will be null if the input [TextEditingValue.composing] is
|
||||||
|
/// invalid or collapsed.
|
||||||
|
final _MutableTextRange? composingRegion;
|
||||||
|
|
||||||
|
// Whether this state object has reached its end-of-life.
|
||||||
|
bool debugFinalized = false;
|
||||||
|
|
||||||
|
TextEditingValue finalize() {
|
||||||
|
debugFinalized = true;
|
||||||
|
final _MutableTextRange? selection = this.selection;
|
||||||
|
final _MutableTextRange? composingRegion = this.composingRegion;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: stringBuffer.toString(),
|
||||||
|
composing: composingRegion == null || composingRegion.base == composingRegion.extent
|
||||||
|
? TextRange.empty
|
||||||
|
: TextRange(start: composingRegion.base, end: composingRegion.extent),
|
||||||
|
selection: selection == null
|
||||||
|
? const TextSelection.collapsed(offset: -1)
|
||||||
|
: TextSelection(
|
||||||
|
baseOffset: selection.base,
|
||||||
|
extentOffset: selection.extent,
|
||||||
|
// Try to preserve the selection affinity and isDirectional. This
|
||||||
|
// may not make sense if the selection has changed.
|
||||||
|
affinity: inputValue.selection.affinity,
|
||||||
|
isDirectional: inputValue.selection.isDirectional,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A [TextInputFormatter] that prevents the insertion of characters
|
/// A [TextInputFormatter] that prevents the insertion of characters
|
||||||
/// matching (or not matching) a particular pattern.
|
/// matching (or not matching) a particular pattern.
|
||||||
///
|
///
|
||||||
@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter {
|
|||||||
/// The [filterPattern] and [replacementString] arguments
|
/// The [filterPattern] and [replacementString] arguments
|
||||||
/// must not be null.
|
/// must not be null.
|
||||||
FilteringTextInputFormatter.allow(
|
FilteringTextInputFormatter.allow(
|
||||||
this.filterPattern, {
|
Pattern filterPattern, {
|
||||||
this.replacementString = '',
|
String replacementString = '',
|
||||||
}) : assert(filterPattern != null),
|
}) : this(filterPattern, allow: true, replacementString: replacementString);
|
||||||
assert(replacementString != null),
|
|
||||||
allow = true;
|
|
||||||
|
|
||||||
/// Creates a formatter that blocks characters matching a pattern.
|
/// Creates a formatter that blocks characters matching a pattern.
|
||||||
///
|
///
|
||||||
/// The [filterPattern] and [replacementString] arguments
|
/// The [filterPattern] and [replacementString] arguments
|
||||||
/// must not be null.
|
/// must not be null.
|
||||||
FilteringTextInputFormatter.deny(
|
FilteringTextInputFormatter.deny(
|
||||||
this.filterPattern, {
|
Pattern filterPattern, {
|
||||||
this.replacementString = '',
|
String replacementString = '',
|
||||||
}) : assert(filterPattern != null),
|
}) : this(filterPattern, allow: false, replacementString: replacementString);
|
||||||
assert(replacementString != null),
|
|
||||||
allow = false;
|
|
||||||
|
|
||||||
/// A [Pattern] to match and replace in incoming [TextEditingValue]s.
|
/// A [Pattern] to match or replace in incoming [TextEditingValue]s.
|
||||||
///
|
///
|
||||||
/// The behavior of the pattern depends on the [allow] property. If
|
/// The behavior of the pattern depends on the [allow] property. If
|
||||||
/// it is true, then this is an allow list, specifying a pattern that
|
/// it is true, then this is an allow list, specifying a pattern that
|
||||||
/// characters must match to be accepted. Otherwise, it is a deny list,
|
/// characters must match to be accepted. Otherwise, it is a deny list,
|
||||||
/// specifying a pattern that characters must not match to be accepted.
|
/// specifying a pattern that characters must not match to be accepted.
|
||||||
///
|
///
|
||||||
/// In general, the pattern should only match one character at a
|
|
||||||
/// time. See the discussion at [replacementString].
|
|
||||||
///
|
|
||||||
/// {@tool snippet}
|
/// {@tool snippet}
|
||||||
/// Typically the pattern is a regular expression, as in:
|
/// Typically the pattern is a regular expression, as in:
|
||||||
///
|
///
|
||||||
@ -246,16 +327,18 @@ class FilteringTextInputFormatter extends TextInputFormatter {
|
|||||||
/// string) because both of the "o"s would be matched simultaneously
|
/// string) because both of the "o"s would be matched simultaneously
|
||||||
/// by the pattern.
|
/// by the pattern.
|
||||||
///
|
///
|
||||||
/// Additionally, each segment of the string before, during, and
|
/// The filter may adjust the selection and the composing region of the text
|
||||||
/// after the current selection in the [TextEditingValue] is handled
|
/// after applying the text replacement, such that they still cover the same
|
||||||
/// separately. This means that, in the case of the "Into the Woods"
|
/// text. For instance, if the pattern was `o+` and the last character "s" was
|
||||||
/// example above, if the selection ended between the two "o"s in
|
/// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|",
|
||||||
/// "Woods", even if the pattern was `RegExp('o+')`, the result
|
/// with the selection still around the same character "s" despite that it is
|
||||||
/// would be "Int* the W**ds", since the two "o"s would be handled
|
/// now the 12th character.
|
||||||
/// in separate passes.
|
|
||||||
///
|
///
|
||||||
/// See also [String.splitMapJoin], which is used to implement this
|
/// In the case where one end point of the selection (or the composing region)
|
||||||
/// behavior in both cases.
|
/// is strictly inside the banned pattern (for example, "Into The |Wo|ods"),
|
||||||
|
/// that endpoint will be moved to the end of the replacement string (it will
|
||||||
|
/// become "Into The |W*|ds" if the pattern was `o+` and the original text and
|
||||||
|
/// selection were "Into The |Wo|ods").
|
||||||
final String replacementString;
|
final String replacementString;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -263,16 +346,59 @@ class FilteringTextInputFormatter extends TextInputFormatter {
|
|||||||
TextEditingValue oldValue, // unused.
|
TextEditingValue oldValue, // unused.
|
||||||
TextEditingValue newValue,
|
TextEditingValue newValue,
|
||||||
) {
|
) {
|
||||||
return _selectionAwareTextManipulation(
|
final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator(newValue);
|
||||||
newValue,
|
assert(!formatState.debugFinalized);
|
||||||
(String substring) {
|
|
||||||
return substring.splitMapJoin(
|
final Iterable<Match> matches = filterPattern.allMatches(newValue.text);
|
||||||
filterPattern,
|
Match? previousMatch;
|
||||||
onMatch: !allow ? (Match match) => replacementString : null,
|
for (final Match match in matches) {
|
||||||
onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null,
|
assert(match.end >= match.start);
|
||||||
);
|
// Compute the non-match region between this `Match` and the previous
|
||||||
},
|
// `Match`. Depending on the value of `allow`, either the match region or
|
||||||
);
|
// the non-match region is the banned pattern.
|
||||||
|
//
|
||||||
|
// The non-matching region.
|
||||||
|
_processRegion(allow, previousMatch?.end ?? 0, match.start, formatState);
|
||||||
|
assert(!formatState.debugFinalized);
|
||||||
|
// The matched region.
|
||||||
|
_processRegion(!allow, match.start, match.end, formatState);
|
||||||
|
assert(!formatState.debugFinalized);
|
||||||
|
|
||||||
|
previousMatch = match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the last non-matching region between the last match region and the
|
||||||
|
// end of the text.
|
||||||
|
_processRegion(allow, previousMatch?.end ?? 0, newValue.text.length, formatState);
|
||||||
|
assert(!formatState.debugFinalized);
|
||||||
|
return formatState.finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _processRegion(bool isBannedRegion, int regionStart, int regionEnd, _TextEditingValueAccumulator state) {
|
||||||
|
final String replacementString = isBannedRegion
|
||||||
|
? (regionStart == regionEnd ? '' : this.replacementString)
|
||||||
|
: state.inputValue.text.substring(regionStart, regionEnd);
|
||||||
|
|
||||||
|
state.stringBuffer.write(replacementString);
|
||||||
|
|
||||||
|
if (replacementString.length == regionEnd - regionStart) {
|
||||||
|
// We don't have to adjust the indices if the replaced string and the
|
||||||
|
// replacement string have the same length.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int adjustIndex(int originalIndex) {
|
||||||
|
// The length added by adding the replacementString.
|
||||||
|
final int replacedLength = originalIndex <= regionStart && originalIndex < regionEnd ? 0 : replacementString.length;
|
||||||
|
// The length removed by removing the replacementRange.
|
||||||
|
final int removedLength = originalIndex.clamp(regionStart, regionEnd) - regionStart;
|
||||||
|
return replacedLength - removedLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selection?.base += adjustIndex(state.inputValue.selection.baseOffset);
|
||||||
|
state.selection?.extent += adjustIndex(state.inputValue.selection.extentOffset);
|
||||||
|
state.composingRegion?.base += adjustIndex(state.inputValue.composing.start);
|
||||||
|
state.composingRegion?.extent += adjustIndex(state.inputValue.composing.end);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [TextInputFormatter] that forces input to be a single line.
|
/// A [TextInputFormatter] that forces input to be a single line.
|
||||||
@ -527,45 +653,3 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TextEditingValue _selectionAwareTextManipulation(
|
|
||||||
TextEditingValue value,
|
|
||||||
String Function(String substring) substringManipulation,
|
|
||||||
) {
|
|
||||||
final int selectionStartIndex = value.selection.start;
|
|
||||||
final int selectionEndIndex = value.selection.end;
|
|
||||||
String manipulatedText;
|
|
||||||
TextSelection? manipulatedSelection;
|
|
||||||
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
|
|
||||||
manipulatedText = substringManipulation(value.text);
|
|
||||||
} else {
|
|
||||||
final String beforeSelection = substringManipulation(
|
|
||||||
value.text.substring(0, selectionStartIndex),
|
|
||||||
);
|
|
||||||
final String inSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionStartIndex, selectionEndIndex),
|
|
||||||
);
|
|
||||||
final String afterSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionEndIndex),
|
|
||||||
);
|
|
||||||
manipulatedText = beforeSelection + inSelection + afterSelection;
|
|
||||||
if (value.selection.baseOffset > value.selection.extentOffset) {
|
|
||||||
manipulatedSelection = value.selection.copyWith(
|
|
||||||
baseOffset: beforeSelection.length + inSelection.length,
|
|
||||||
extentOffset: beforeSelection.length,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
manipulatedSelection = value.selection.copyWith(
|
|
||||||
baseOffset: beforeSelection.length,
|
|
||||||
extentOffset: beforeSelection.length + inSelection.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TextEditingValue(
|
|
||||||
text: manipulatedText,
|
|
||||||
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
|
|
||||||
composing: manipulatedText == value.text
|
|
||||||
? value.composing
|
|
||||||
: TextRange.empty,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -773,9 +773,38 @@ class TextEditingValue {
|
|||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
/// The range of text that is currently selected.
|
/// The range of text that is currently selected.
|
||||||
|
///
|
||||||
|
/// When [selection] is a [TextSelection] that has the same non-negative
|
||||||
|
/// `baseOffset` and `extentOffset`, the [selection] property represents the
|
||||||
|
/// caret position.
|
||||||
|
///
|
||||||
|
/// If the current [selection] has a negative `baseOffset` or `extentOffset`,
|
||||||
|
/// then the text currently does not have a selection or a caret location, and
|
||||||
|
/// most text editing operations that rely on the current selection (for
|
||||||
|
/// instance, insert a character at the caret location) will do nothing.
|
||||||
final TextSelection selection;
|
final TextSelection selection;
|
||||||
|
|
||||||
/// The range of text that is still being composed.
|
/// The range of text that is still being composed.
|
||||||
|
///
|
||||||
|
/// Composing regions are created by input methods (IMEs) to indicate the text
|
||||||
|
/// within a certain range is provisional. For instance, the Android Gboard
|
||||||
|
/// app's English keyboard puts the current word under the caret into a
|
||||||
|
/// composing region to indicate the word is subject to autocorrect or
|
||||||
|
/// prediction changes.
|
||||||
|
///
|
||||||
|
/// Composing regions can also be used for performing multistage input, which
|
||||||
|
/// is typically used by IMEs designed for phoetic keyboard to enter
|
||||||
|
/// ideographic symbols. As an example, many CJK keyboards require the user to
|
||||||
|
/// enter a latin alphabet sequence and then convert it to CJK characters. On
|
||||||
|
/// iOS, the default software keyboards do not have a dedicated view to show
|
||||||
|
/// the unfinished latin sequence, so it's displayed directly in the text
|
||||||
|
/// field, inside of a composing region.
|
||||||
|
///
|
||||||
|
/// The composing region should typically only be changed by the IME, or the
|
||||||
|
/// user via interacting with the IME.
|
||||||
|
///
|
||||||
|
/// If the range represented by this property is [TextRange.empty], then the
|
||||||
|
/// text is not currently being composed.
|
||||||
final TextRange composing;
|
final TextRange composing;
|
||||||
|
|
||||||
/// A value that corresponds to the empty string with no selection and no composing range.
|
/// A value that corresponds to the empty string with no selection and no composing range.
|
||||||
|
@ -2375,10 +2375,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
final bool selectionChanged = _value.selection != value.selection;
|
final bool selectionChanged = _value.selection != value.selection;
|
||||||
|
|
||||||
if (textChanged) {
|
if (textChanged) {
|
||||||
value = widget.inputFormatters?.fold<TextEditingValue>(
|
try {
|
||||||
value,
|
value = widget.inputFormatters?.fold<TextEditingValue>(
|
||||||
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
|
value,
|
||||||
) ?? value;
|
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
|
||||||
|
) ?? value;
|
||||||
|
} catch (exception, stack) {
|
||||||
|
FlutterError.reportError(FlutterErrorDetails(
|
||||||
|
exception: exception,
|
||||||
|
stack: stack,
|
||||||
|
library: 'widgets',
|
||||||
|
context: ErrorDescription('while applying input formatters'),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Put all optional user callback invocations in a batch edit to prevent
|
// Put all optional user callback invocations in a batch edit to prevent
|
||||||
|
@ -64,6 +64,7 @@ void main() {
|
|||||||
const TextEditingValue(text: 'Int* the W*ds'),
|
const TextEditingValue(text: 'Int* the W*ds'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// "Into the Wo|ods|"
|
||||||
const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14));
|
const TextEditingValue selectedIntoTheWoods = TextEditingValue(text: 'Into the Woods', selection: TextSelection(baseOffset: 11, extentOffset: 14));
|
||||||
expect(
|
expect(
|
||||||
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
|
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
|
||||||
@ -79,7 +80,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
|
FilteringTextInputFormatter(RegExp('o+'), allow: false, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
|
||||||
const TextEditingValue(text: 'Int* the W**ds', selection: TextSelection(baseOffset: 11, extentOffset: 14)),
|
const TextEditingValue(text: 'Int* the W*ds', selection: TextSelection(baseOffset: 11, extentOffset: 13)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -624,4 +625,247 @@ void main() {
|
|||||||
// cursor must be now at fourth position (right after the number 9)
|
// cursor must be now at fourth position (right after the number 9)
|
||||||
expect(formatted.selection.baseOffset, equals(4));
|
expect(formatted.selection.baseOffset, equals(4));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('FilteringTextInputFormatter should filter independent of selection', () {
|
||||||
|
// Regression test for https://github.com/flutter/flutter/issues/80842.
|
||||||
|
|
||||||
|
final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*');
|
||||||
|
|
||||||
|
const TextEditingValue oldValue = TextEditingValue.empty;
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc');
|
||||||
|
|
||||||
|
final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text;
|
||||||
|
|
||||||
|
for (int i = 0; i < newValue.text.length; i += 1) {
|
||||||
|
final String text = formatter.formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(selection: TextSelection.collapsed(offset: i)),
|
||||||
|
).text;
|
||||||
|
expect(filteredText, text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FilteringTextInputFormatter should filter independent of composingRegion', () {
|
||||||
|
final TextInputFormatter formatter = FilteringTextInputFormatter.deny('abc', replacementString: '*');
|
||||||
|
|
||||||
|
const TextEditingValue oldValue = TextEditingValue.empty;
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'abcabcabc');
|
||||||
|
|
||||||
|
final String filteredText = formatter.formatEditUpdate(oldValue, newValue).text;
|
||||||
|
|
||||||
|
for (int i = 0; i < newValue.text.length; i += 1) {
|
||||||
|
final String text = formatter.formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(composing: TextRange.collapsed(i)),
|
||||||
|
).text;
|
||||||
|
expect(filteredText, text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FilteringTextInputFormatter basic filtering test', () {
|
||||||
|
final RegExp filter = RegExp('[A-Za-z0-9.@-]*');
|
||||||
|
final TextInputFormatter formatter = FilteringTextInputFormatter.allow(filter);
|
||||||
|
|
||||||
|
const TextEditingValue oldValue = TextEditingValue.empty;
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'ab&&ca@bcabc');
|
||||||
|
|
||||||
|
expect(formatter.formatEditUpdate(oldValue, newValue).text, 'abca@bcabc');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('FilteringTextInputFormatter region', () {
|
||||||
|
const TextEditingValue oldValue = TextEditingValue.empty;
|
||||||
|
|
||||||
|
test('Preserves selection region', () {
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
|
||||||
|
|
||||||
|
// AAA | BBB | CCC => AAA | **** | CCC
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 7, extentOffset: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA | BBB CCC | => AAA | **** CCC |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 9, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 10, extentOffset: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA BBB | CCC | => AAA **** | CCC |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 9, extentOffset: 6),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 10, extentOffset: 7),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAAB | B | BCCC => AAA***|CCC
|
||||||
|
// Same length replacement, keep the selection at where it is.
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 5, extentOffset: 4),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 5, extentOffset: 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA | BBB | CCC => AAA | CCC
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 3, extentOffset: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 3, extentOffset: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The unfortunate case, we don't know for sure where to put the selection
|
||||||
|
// so put it after the replacement string.
|
||||||
|
// AAAB|B|BCCC => AAA****|CCC
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 5, extentOffset: 4),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 7, extentOffset: 7),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Preserves selection region, allow', () {
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
|
||||||
|
|
||||||
|
// AAA | BBB | CCC => **** | BBB | ****
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 6, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 7, extentOffset: 4),
|
||||||
|
);
|
||||||
|
|
||||||
|
// | AAABBBCCC | => | ****BBB**** |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 9, extentOffset: 0),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 11, extentOffset: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAABBB | CCC | => ****BBB | **** |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.allow('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
selection: const TextSelection(baseOffset: 9, extentOffset: 6),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 11, extentOffset: 7),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Overlapping matches: AAA | BBBBB | CCC => | BBB |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.allow('BBB', replacementString: '').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
const TextEditingValue(
|
||||||
|
text: 'AAABBBBBCCC',
|
||||||
|
selection: TextSelection(baseOffset: 8, extentOffset: 3),
|
||||||
|
),
|
||||||
|
).selection,
|
||||||
|
const TextSelection(baseOffset: 3, extentOffset: 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Preserves composing region', () {
|
||||||
|
const TextEditingValue newValue = TextEditingValue(text: 'AAABBBCCC');
|
||||||
|
|
||||||
|
// AAA | BBB | CCC => AAA | **** | CCC
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
composing: const TextRange(start: 3, end: 6),
|
||||||
|
),
|
||||||
|
).composing,
|
||||||
|
const TextRange(start: 3, end: 7),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA | BBB CCC | => AAA | **** CCC |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
composing: const TextRange(start: 3, end: 9),
|
||||||
|
),
|
||||||
|
).composing,
|
||||||
|
const TextRange(start: 3, end: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA BBB | CCC | => AAA **** | CCC |
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '****').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
composing: const TextRange(start: 6, end: 9),
|
||||||
|
),
|
||||||
|
).composing,
|
||||||
|
const TextRange(start: 7, end: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAAB | B | BCCC => AAA*** | CCC
|
||||||
|
// Same length replacement, don't move the composing region.
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '***').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
composing: const TextRange(start: 4, end: 5),
|
||||||
|
),
|
||||||
|
).composing,
|
||||||
|
const TextRange(start: 4, end: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
// AAA | BBB | CCC => | AAA CCC
|
||||||
|
expect(
|
||||||
|
FilteringTextInputFormatter.deny('BBB', replacementString: '').formatEditUpdate(
|
||||||
|
oldValue,
|
||||||
|
newValue.copyWith(
|
||||||
|
composing: const TextRange(start: 3, end: 6),
|
||||||
|
),
|
||||||
|
).composing,
|
||||||
|
TextRange.empty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -7750,6 +7750,39 @@ void main() {
|
|||||||
expect(error, isFlutterError);
|
expect(error, isFlutterError);
|
||||||
expect(error.toString(), contains(errorText));
|
expect(error.toString(), contains(errorText));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('input formatters can throw errors', (WidgetTester tester) async {
|
||||||
|
final TextInputFormatter badFormatter = TextInputFormatter.withFunction(
|
||||||
|
(TextEditingValue oldValue, TextEditingValue newValue) => throw FlutterError(errorText),
|
||||||
|
);
|
||||||
|
final TextEditingController controller = TextEditingController(
|
||||||
|
text: 'flutter is the best!',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: EditableText(
|
||||||
|
showSelectionHandles: true,
|
||||||
|
maxLines: 2,
|
||||||
|
controller: controller,
|
||||||
|
inputFormatters: <TextInputFormatter>[badFormatter],
|
||||||
|
focusNode: FocusNode(),
|
||||||
|
cursorColor: Colors.red,
|
||||||
|
backgroundCursorColor: Colors.blue,
|
||||||
|
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1!.copyWith(fontFamily: 'Roboto'),
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Interact with the field to establish the input connection.
|
||||||
|
await tester.tap(find.byType(EditableText));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(EditableText), 'text');
|
||||||
|
|
||||||
|
final dynamic error = tester.takeException();
|
||||||
|
expect(error, isFlutterError);
|
||||||
|
expect(error.toString(), contains(errorText));
|
||||||
|
expect(controller.text, 'text');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regression test for https://github.com/flutter/flutter/issues/72400.
|
// Regression test for https://github.com/flutter/flutter/issues/72400.
|
||||||
|
Loading…
Reference in New Issue
Block a user