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
|
||||
/// matching (or not matching) a particular pattern.
|
||||
///
|
||||
@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter {
|
||||
/// The [filterPattern] and [replacementString] arguments
|
||||
/// must not be null.
|
||||
FilteringTextInputFormatter.allow(
|
||||
this.filterPattern, {
|
||||
this.replacementString = '',
|
||||
}) : assert(filterPattern != null),
|
||||
assert(replacementString != null),
|
||||
allow = true;
|
||||
Pattern filterPattern, {
|
||||
String replacementString = '',
|
||||
}) : this(filterPattern, allow: true, replacementString: replacementString);
|
||||
|
||||
/// Creates a formatter that blocks characters matching a pattern.
|
||||
///
|
||||
/// The [filterPattern] and [replacementString] arguments
|
||||
/// must not be null.
|
||||
FilteringTextInputFormatter.deny(
|
||||
this.filterPattern, {
|
||||
this.replacementString = '',
|
||||
}) : assert(filterPattern != null),
|
||||
assert(replacementString != null),
|
||||
allow = false;
|
||||
Pattern filterPattern, {
|
||||
String replacementString = '',
|
||||
}) : this(filterPattern, allow: false, replacementString: replacementString);
|
||||
|
||||
/// 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
|
||||
/// 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,
|
||||
/// 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}
|
||||
/// 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
|
||||
/// by the pattern.
|
||||
///
|
||||
/// Additionally, each segment of the string before, during, and
|
||||
/// after the current selection in the [TextEditingValue] is handled
|
||||
/// separately. This means that, in the case of the "Into the Woods"
|
||||
/// example above, if the selection ended between the two "o"s in
|
||||
/// "Woods", even if the pattern was `RegExp('o+')`, the result
|
||||
/// would be "Int* the W**ds", since the two "o"s would be handled
|
||||
/// in separate passes.
|
||||
/// The filter may adjust the selection and the composing region of the text
|
||||
/// after applying the text replacement, such that they still cover the same
|
||||
/// text. For instance, if the pattern was `o+` and the last character "s" was
|
||||
/// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|",
|
||||
/// with the selection still around the same character "s" despite that it is
|
||||
/// now the 12th character.
|
||||
///
|
||||
/// See also [String.splitMapJoin], which is used to implement this
|
||||
/// behavior in both cases.
|
||||
/// In the case where one end point of the selection (or the composing region)
|
||||
/// 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;
|
||||
|
||||
@override
|
||||
@ -263,16 +346,59 @@ class FilteringTextInputFormatter extends TextInputFormatter {
|
||||
TextEditingValue oldValue, // unused.
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
return _selectionAwareTextManipulation(
|
||||
newValue,
|
||||
(String substring) {
|
||||
return substring.splitMapJoin(
|
||||
filterPattern,
|
||||
onMatch: !allow ? (Match match) => replacementString : null,
|
||||
onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator(newValue);
|
||||
assert(!formatState.debugFinalized);
|
||||
|
||||
final Iterable<Match> matches = filterPattern.allMatches(newValue.text);
|
||||
Match? previousMatch;
|
||||
for (final Match match in matches) {
|
||||
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.
|
||||
@ -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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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;
|
||||
|
||||
if (textChanged) {
|
||||
value = widget.inputFormatters?.fold<TextEditingValue>(
|
||||
value,
|
||||
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
|
||||
) ?? value;
|
||||
try {
|
||||
value = widget.inputFormatters?.fold<TextEditingValue>(
|
||||
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
|
||||
|
@ -64,6 +64,7 @@ void main() {
|
||||
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));
|
||||
expect(
|
||||
FilteringTextInputFormatter('o', allow: true, replacementString: '*').formatEditUpdate(testOldValue, selectedIntoTheWoods),
|
||||
@ -79,7 +80,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
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)
|
||||
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.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.
|
||||
|
Loading…
Reference in New Issue
Block a user