Skip to content

Commit 7684f8b

Browse files
Reland "Make FilteringTextInputFormatter's filtering Selection/Composing Region agnostic" flutter#89327 (flutter#90211)
1 parent ab51a02 commit 7684f8b

File tree

5 files changed

+480
-81
lines changed

5 files changed

+480
-81
lines changed

packages/flutter/lib/src/services/text_formatter.dart

Lines changed: 160 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,94 @@ class _SimpleTextInputFormatter extends TextInputFormatter {
121121
}
122122
}
123123

124+
// A mutable, half-open range [`base`, `extent`) within a string.
125+
class _MutableTextRange {
126+
_MutableTextRange(this.base, this.extent);
127+
128+
static _MutableTextRange? fromComposingRange(TextRange range) {
129+
return range.isValid && !range.isCollapsed
130+
? _MutableTextRange(range.start, range.end)
131+
: null;
132+
}
133+
134+
static _MutableTextRange? fromTextSelection(TextSelection selection) {
135+
return selection.isValid
136+
? _MutableTextRange(selection.baseOffset, selection.extentOffset)
137+
: null;
138+
}
139+
140+
/// The start index of the range, inclusive.
141+
///
142+
/// The value of [base] should always be greater than or equal to 0, and can
143+
/// be larger than, smaller than, or equal to [extent].
144+
int base;
145+
146+
/// The end index of the range, exclusive.
147+
///
148+
/// The value of [extent] should always be greater than or equal to 0, and can
149+
/// be larger than, smaller than, or equal to [base].
150+
int extent;
151+
}
152+
153+
// The intermediate state of a [FilteringTextInputFormatter] when it's
154+
// formatting a new user input.
155+
class _TextEditingValueAccumulator {
156+
_TextEditingValueAccumulator(this.inputValue)
157+
: selection = _MutableTextRange.fromTextSelection(inputValue.selection),
158+
composingRegion = _MutableTextRange.fromComposingRange(inputValue.composing);
159+
160+
// The original string that was sent to the [FilteringTextInputFormatter] as
161+
// input.
162+
final TextEditingValue inputValue;
163+
164+
/// The [StringBuffer] that contains the string which has already been
165+
/// formatted.
166+
///
167+
/// In a [FilteringTextInputFormatter], typically the replacement string,
168+
/// instead of the original string within the given range, is written to this
169+
/// [StringBuffer].
170+
final StringBuffer stringBuffer = StringBuffer();
171+
172+
/// The updated selection, as well as the original selection from the input
173+
/// [TextEditingValue] of the [FilteringTextInputFormatter].
174+
///
175+
/// This parameter will be null if the input [TextEditingValue.selection] is
176+
/// invalid.
177+
final _MutableTextRange? selection;
178+
179+
/// The updated composing region, as well as the original composing region
180+
/// from the input [TextEditingValue] of the [FilteringTextInputFormatter].
181+
///
182+
/// This parameter will be null if the input [TextEditingValue.composing] is
183+
/// invalid or collapsed.
184+
final _MutableTextRange? composingRegion;
185+
186+
// Whether this state object has reached its end-of-life.
187+
bool debugFinalized = false;
188+
189+
TextEditingValue finalize() {
190+
debugFinalized = true;
191+
final _MutableTextRange? selection = this.selection;
192+
final _MutableTextRange? composingRegion = this.composingRegion;
193+
return TextEditingValue(
194+
text: stringBuffer.toString(),
195+
composing: composingRegion == null || composingRegion.base == composingRegion.extent
196+
? TextRange.empty
197+
: TextRange(start: composingRegion.base, end: composingRegion.extent),
198+
selection: selection == null
199+
? const TextSelection.collapsed(offset: -1)
200+
: TextSelection(
201+
baseOffset: selection.base,
202+
extentOffset: selection.extent,
203+
// Try to preserve the selection affinity and isDirectional. This
204+
// may not make sense if the selection has changed.
205+
affinity: inputValue.selection.affinity,
206+
isDirectional: inputValue.selection.isDirectional,
207+
),
208+
);
209+
}
210+
}
211+
124212
/// A [TextInputFormatter] that prevents the insertion of characters
125213
/// matching (or not matching) a particular pattern.
126214
///
@@ -159,33 +247,26 @@ class FilteringTextInputFormatter extends TextInputFormatter {
159247
/// The [filterPattern] and [replacementString] arguments
160248
/// must not be null.
161249
FilteringTextInputFormatter.allow(
162-
this.filterPattern, {
163-
this.replacementString = '',
164-
}) : assert(filterPattern != null),
165-
assert(replacementString != null),
166-
allow = true;
250+
Pattern filterPattern, {
251+
String replacementString = '',
252+
}) : this(filterPattern, allow: true, replacementString: replacementString);
167253

168254
/// Creates a formatter that blocks characters matching a pattern.
169255
///
170256
/// The [filterPattern] and [replacementString] arguments
171257
/// must not be null.
172258
FilteringTextInputFormatter.deny(
173-
this.filterPattern, {
174-
this.replacementString = '',
175-
}) : assert(filterPattern != null),
176-
assert(replacementString != null),
177-
allow = false;
259+
Pattern filterPattern, {
260+
String replacementString = '',
261+
}) : this(filterPattern, allow: false, replacementString: replacementString);
178262

179-
/// A [Pattern] to match and replace in incoming [TextEditingValue]s.
263+
/// A [Pattern] to match or replace in incoming [TextEditingValue]s.
180264
///
181265
/// The behavior of the pattern depends on the [allow] property. If
182266
/// it is true, then this is an allow list, specifying a pattern that
183267
/// characters must match to be accepted. Otherwise, it is a deny list,
184268
/// specifying a pattern that characters must not match to be accepted.
185269
///
186-
/// In general, the pattern should only match one character at a
187-
/// time. See the discussion at [replacementString].
188-
///
189270
/// {@tool snippet}
190271
/// Typically the pattern is a regular expression, as in:
191272
///
@@ -246,33 +327,78 @@ class FilteringTextInputFormatter extends TextInputFormatter {
246327
/// string) because both of the "o"s would be matched simultaneously
247328
/// by the pattern.
248329
///
249-
/// Additionally, each segment of the string before, during, and
250-
/// after the current selection in the [TextEditingValue] is handled
251-
/// separately. This means that, in the case of the "Into the Woods"
252-
/// example above, if the selection ended between the two "o"s in
253-
/// "Woods", even if the pattern was `RegExp('o+')`, the result
254-
/// would be "Int* the W**ds", since the two "o"s would be handled
255-
/// in separate passes.
256-
///
257-
/// See also [String.splitMapJoin], which is used to implement this
258-
/// behavior in both cases.
330+
/// The filter may adjust the selection and the composing region of the text
331+
/// after applying the text replacement, such that they still cover the same
332+
/// text. For instance, if the pattern was `o+` and the last character "s" was
333+
/// selected: "Into The Wood|s|", then the result will be "Into The W*d|s|",
334+
/// with the selection still around the same character "s" despite that it is
335+
/// now the 12th character.
336+
///
337+
/// In the case where one end point of the selection (or the composing region)
338+
/// is strictly inside the banned pattern (for example, "Into The |Wo|ods"),
339+
/// that endpoint will be moved to the end of the replacement string (it will
340+
/// become "Into The |W*|ds" if the pattern was `o+` and the original text and
341+
/// selection were "Into The |Wo|ods").
259342
final String replacementString;
260343

261344
@override
262345
TextEditingValue formatEditUpdate(
263346
TextEditingValue oldValue, // unused.
264347
TextEditingValue newValue,
265348
) {
266-
return _selectionAwareTextManipulation(
267-
newValue,
268-
(String substring) {
269-
return substring.splitMapJoin(
270-
filterPattern,
271-
onMatch: !allow ? (Match match) => replacementString : null,
272-
onNonMatch: allow ? (String nonMatch) => nonMatch.isNotEmpty ? replacementString : '' : null,
273-
);
274-
},
275-
);
349+
final _TextEditingValueAccumulator formatState = _TextEditingValueAccumulator(newValue);
350+
assert(!formatState.debugFinalized);
351+
352+
final Iterable<Match> matches = filterPattern.allMatches(newValue.text);
353+
Match? previousMatch;
354+
for (final Match match in matches) {
355+
assert(match.end >= match.start);
356+
// Compute the non-match region between this `Match` and the previous
357+
// `Match`. Depending on the value of `allow`, either the match region or
358+
// the non-match region is the banned pattern.
359+
//
360+
// The non-matching region.
361+
_processRegion(allow, previousMatch?.end ?? 0, match.start, formatState);
362+
assert(!formatState.debugFinalized);
363+
// The matched region.
364+
_processRegion(!allow, match.start, match.end, formatState);
365+
assert(!formatState.debugFinalized);
366+
367+
previousMatch = match;
368+
}
369+
370+
// Handle the last non-matching region between the last match region and the
371+
// end of the text.
372+
_processRegion(allow, previousMatch?.end ?? 0, newValue.text.length, formatState);
373+
assert(!formatState.debugFinalized);
374+
return formatState.finalize();
375+
}
376+
377+
void _processRegion(bool isBannedRegion, int regionStart, int regionEnd, _TextEditingValueAccumulator state) {
378+
final String replacementString = isBannedRegion
379+
? (regionStart == regionEnd ? '' : this.replacementString)
380+
: state.inputValue.text.substring(regionStart, regionEnd);
381+
382+
state.stringBuffer.write(replacementString);
383+
384+
if (replacementString.length == regionEnd - regionStart) {
385+
// We don't have to adjust the indices if the replaced string and the
386+
// replacement string have the same length.
387+
return;
388+
}
389+
390+
int adjustIndex(int originalIndex) {
391+
// The length added by adding the replacementString.
392+
final int replacedLength = originalIndex <= regionStart && originalIndex < regionEnd ? 0 : replacementString.length;
393+
// The length removed by removing the replacementRange.
394+
final int removedLength = originalIndex.clamp(regionStart, regionEnd) - regionStart;
395+
return replacedLength - removedLength;
396+
}
397+
398+
state.selection?.base += adjustIndex(state.inputValue.selection.baseOffset);
399+
state.selection?.extent += adjustIndex(state.inputValue.selection.extentOffset);
400+
state.composingRegion?.base += adjustIndex(state.inputValue.composing.start);
401+
state.composingRegion?.extent += adjustIndex(state.inputValue.composing.end);
276402
}
277403

278404
/// A [TextInputFormatter] that forces input to be a single line.
@@ -527,45 +653,3 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
527653
}
528654
}
529655
}
530-
531-
TextEditingValue _selectionAwareTextManipulation(
532-
TextEditingValue value,
533-
String Function(String substring) substringManipulation,
534-
) {
535-
final int selectionStartIndex = value.selection.start;
536-
final int selectionEndIndex = value.selection.end;
537-
String manipulatedText;
538-
TextSelection? manipulatedSelection;
539-
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
540-
manipulatedText = substringManipulation(value.text);
541-
} else {
542-
final String beforeSelection = substringManipulation(
543-
value.text.substring(0, selectionStartIndex),
544-
);
545-
final String inSelection = substringManipulation(
546-
value.text.substring(selectionStartIndex, selectionEndIndex),
547-
);
548-
final String afterSelection = substringManipulation(
549-
value.text.substring(selectionEndIndex),
550-
);
551-
manipulatedText = beforeSelection + inSelection + afterSelection;
552-
if (value.selection.baseOffset > value.selection.extentOffset) {
553-
manipulatedSelection = value.selection.copyWith(
554-
baseOffset: beforeSelection.length + inSelection.length,
555-
extentOffset: beforeSelection.length,
556-
);
557-
} else {
558-
manipulatedSelection = value.selection.copyWith(
559-
baseOffset: beforeSelection.length,
560-
extentOffset: beforeSelection.length + inSelection.length,
561-
);
562-
}
563-
}
564-
return TextEditingValue(
565-
text: manipulatedText,
566-
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
567-
composing: manipulatedText == value.text
568-
? value.composing
569-
: TextRange.empty,
570-
);
571-
}

packages/flutter/lib/src/services/text_input.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,9 +773,38 @@ class TextEditingValue {
773773
final String text;
774774

775775
/// The range of text that is currently selected.
776+
///
777+
/// When [selection] is a [TextSelection] that has the same non-negative
778+
/// `baseOffset` and `extentOffset`, the [selection] property represents the
779+
/// caret position.
780+
///
781+
/// If the current [selection] has a negative `baseOffset` or `extentOffset`,
782+
/// then the text currently does not have a selection or a caret location, and
783+
/// most text editing operations that rely on the current selection (for
784+
/// instance, insert a character at the caret location) will do nothing.
776785
final TextSelection selection;
777786

778787
/// The range of text that is still being composed.
788+
///
789+
/// Composing regions are created by input methods (IMEs) to indicate the text
790+
/// within a certain range is provisional. For instance, the Android Gboard
791+
/// app's English keyboard puts the current word under the caret into a
792+
/// composing region to indicate the word is subject to autocorrect or
793+
/// prediction changes.
794+
///
795+
/// Composing regions can also be used for performing multistage input, which
796+
/// is typically used by IMEs designed for phoetic keyboard to enter
797+
/// ideographic symbols. As an example, many CJK keyboards require the user to
798+
/// enter a latin alphabet sequence and then convert it to CJK characters. On
799+
/// iOS, the default software keyboards do not have a dedicated view to show
800+
/// the unfinished latin sequence, so it's displayed directly in the text
801+
/// field, inside of a composing region.
802+
///
803+
/// The composing region should typically only be changed by the IME, or the
804+
/// user via interacting with the IME.
805+
///
806+
/// If the range represented by this property is [TextRange.empty], then the
807+
/// text is not currently being composed.
779808
final TextRange composing;
780809

781810
/// A value that corresponds to the empty string with no selection and no composing range.

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2375,10 +2375,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
23752375
final bool selectionChanged = _value.selection != value.selection;
23762376

23772377
if (textChanged) {
2378-
value = widget.inputFormatters?.fold<TextEditingValue>(
2379-
value,
2380-
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
2381-
) ?? value;
2378+
try {
2379+
value = widget.inputFormatters?.fold<TextEditingValue>(
2380+
value,
2381+
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
2382+
) ?? value;
2383+
} catch (exception, stack) {
2384+
FlutterError.reportError(FlutterErrorDetails(
2385+
exception: exception,
2386+
stack: stack,
2387+
library: 'widgets',
2388+
context: ErrorDescription('while applying input formatters'),
2389+
));
2390+
}
23822391
}
23832392

23842393
// Put all optional user callback invocations in a batch edit to prevent

0 commit comments

Comments
 (0)