Reland formatter changes (#52765)

This commit is contained in:
Gary Qian 2020-03-18 15:56:02 -07:00 committed by GitHub
parent ee845255de
commit ee8131b430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 351 additions and 21 deletions

View file

@ -1230,6 +1230,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// _lastFormattedUnmodifiedTextEditingValue tracks the last value
// that the formatter ran on and is used to prevent double-formatting.
TextEditingValue _lastFormattedUnmodifiedTextEditingValue;
// _lastFormattedValue tracks the last post-format value, so that it can be
// reused without rerunning the formatter when the input value is repeated.
TextEditingValue _lastFormattedValue;
// _receivedRemoteTextEditingValue is the direct value last passed in
// updateEditingValue. This value does not get updated with the formatted
// version.
@ -1660,15 +1663,30 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// Check if the new value is the same as the current local value, or is the same
// as the post-formatting value of the previous pass.
final bool textChanged = _value?.text != value?.text;
final bool isRepeat = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text;
if (textChanged && !isRepeat && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
for (final TextInputFormatter formatter in widget.inputFormatters)
final bool isRepeatText = value?.text == _lastFormattedUnmodifiedTextEditingValue?.text;
final bool isRepeatSelection = value?.selection == _lastFormattedUnmodifiedTextEditingValue?.selection;
final bool isRepeatComposing = value?.composing == _lastFormattedUnmodifiedTextEditingValue?.composing;
// Only format when the text has changed and there are available formatters.
if (!isRepeatText && textChanged && widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
for (final TextInputFormatter formatter in widget.inputFormatters) {
value = formatter.formatEditUpdate(_value, value);
_value = value;
_updateRemoteEditingValueIfNeeded();
} else {
_value = value;
}
// Always pass the text through the whitespace directionality formatter to
// maintain expected behavior with carets on trailing whitespace.
value = _whitespaceFormatter.formatEditUpdate(_value, value);
_lastFormattedValue = value;
}
// If the text, selection, or composing region has changed, we should update the
// locally stored TextEditingValue to the new one.
if (!isRepeatText || !isRepeatSelection || !isRepeatComposing) {
_value = value;
} else if (textChanged && _lastFormattedValue != null) {
_value = _lastFormattedValue;
}
// Always attempt to send the value. If the value has changed, then it will send,
// otherwise, it will short-circuit.
_updateRemoteEditingValueIfNeeded();
if (textChanged && widget.onChanged != null)
widget.onChanged(value.text);
_lastFormattedUnmodifiedTextEditingValue = _receivedRemoteTextEditingValue;
@ -2207,14 +2225,22 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter {
// We add/subtract from these as we insert/remove markers.
int selectionBase = newValue.selection.baseOffset;
int selectionExtent = newValue.selection.extentOffset;
int composingStart = newValue.composing.start;
int composingEnd = newValue.composing.end;
void addToSelection() {
void addToLength() {
selectionBase += outputCodepoints.length <= selectionBase ? 1 : 0;
selectionExtent += outputCodepoints.length <= selectionExtent ? 1 : 0;
composingStart += outputCodepoints.length <= composingStart ? 1 : 0;
composingEnd += outputCodepoints.length <= composingEnd ? 1 : 0;
}
void subtractFromSelection() {
void subtractFromLength() {
selectionBase -= outputCodepoints.length < selectionBase ? 1 : 0;
selectionExtent -= outputCodepoints.length < selectionExtent ? 1 : 0;
composingStart -= outputCodepoints.length < composingStart ? 1 : 0;
composingEnd -= outputCodepoints.length < composingEnd ? 1 : 0;
}
bool previousWasWhitespace = false;
@ -2230,11 +2256,11 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter {
// If we already added directionality for this run of whitespace,
// "shift" the marker added to the end of the whitespace run.
if (previousWasWhitespace) {
subtractFromSelection();
subtractFromLength();
outputCodepoints.removeLast();
}
outputCodepoints.add(codepoint);
addToSelection();
addToLength();
outputCodepoints.add(_previousNonWhitespaceDirection == TextDirection.rtl ? _rlm : _lrm);
previousWasWhitespace = true;
@ -2243,7 +2269,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter {
// Handle pre-existing directionality markers. Use pre-existing marker
// instead of the one we add.
if (previousWasWhitespace) {
subtractFromSelection();
subtractFromLength();
outputCodepoints.removeLast();
}
outputCodepoints.add(codepoint);
@ -2256,7 +2282,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter {
if (!previousWasDirectionalityMarker &&
previousWasWhitespace &&
getDirection(codepoint) == _previousNonWhitespaceDirection) {
subtractFromSelection();
subtractFromLength();
outputCodepoints.removeLast();
}
// Normal character, track its codepoint add it to the string.
@ -2276,6 +2302,7 @@ class _WhitespaceDirectionalityFormatter extends TextInputFormatter {
affinity: newValue.selection.affinity,
isDirectional: newValue.selection.isDirectional
),
composing: TextRange(start: composingStart, end: composingEnd),
);
}
return newValue;

View file

@ -4238,21 +4238,324 @@ void main() {
expect(formatter.log, referenceLog);
});
testWidgets('formatter logic handles repeat filtering', (WidgetTester tester) async {
final MockTextFormatter formatter = MockTextFormatter();
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
inputFormatters: <TextInputFormatter>[formatter],
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
expect(formatter.formatCallCount, 0);
state.updateEditingValue(const TextEditingValue(text: '01'));
expect(formatter.formatCallCount, 1);
state.updateEditingValue(const TextEditingValue(text: '012'));
expect(formatter.formatCallCount, 2);
state.updateEditingValue(const TextEditingValue(text: '0123')); // Text change causes reformat
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123')); // Repeat, does not format
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Selection change does not reformat
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2))); // Repeat, does not format
expect(formatter.formatCallCount, 3);
state.updateEditingValue(const TextEditingValue(text: '0123', selection: TextSelection.collapsed(offset: 2), composing: TextRange(start: 1, end: 2))); // Composing change does not reformat
expect(formatter.formatCallCount, 3);
expect(formatter.lastOldValue.composing, const TextRange(start: -1, end: -1));
expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1));
state.updateEditingValue(const TextEditingValue(text: '01234', selection: TextSelection.collapsed(offset: 2))); // Formats, with oldValue containing composing region.
expect(formatter.formatCallCount, 4);
expect(formatter.lastOldValue.composing, const TextRange(start: 1, end: 2));
expect(formatter.lastNewValue.composing, const TextRange(start: -1, end: -1));
const List<String> referenceLog = <String>[
'[1]: , 01',
'[1]: normal aa',
'[2]: aa, 012',
'[2]: normal aaaa',
'[3]: aaaa, 0123',
'[3]: normal aaaaaa',
'[4]: 0123, 01234',
'[4]: normal aaaaaaaa'
];
expect(formatter.log, referenceLog);
});
testWidgets('Whitespace directionality formatter input Arabic', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'testText');
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.blue,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
// Simple mixed directional input.
state.updateEditingValue(const TextEditingValue(text: 'h'));
state.updateEditingValue(const TextEditingValue(text: 'he'));
state.updateEditingValue(const TextEditingValue(text: 'hel'));
state.updateEditingValue(const TextEditingValue(text: 'hell'));
state.updateEditingValue(const TextEditingValue(text: 'hello'));
expect(state.currentTextEditingValue.text, equals('hello'));
state.updateEditingValue(const TextEditingValue(text: 'hello ', composing: TextRange(start: 4, end: 5)));
expect(state.currentTextEditingValue.text, equals('hello '));
state.updateEditingValue(const TextEditingValue(text: 'hello ا', composing: TextRange(start: 4, end: 6)));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}ا'));
expect(state.currentTextEditingValue.composing, equals(const TextRange(start: 4, end: 7)));
state.updateEditingValue(const TextEditingValue(text: 'hello الْ', composing: TextRange(start: 4, end: 7)));
state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ', composing: TextRange(start: 4, end: 8)));
state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ ', composing: TextRange(start: 4, end: 9)));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ \u{200F}'));
expect(state.currentTextEditingValue.composing, equals(const TextRange(start: 4, end: 10)));
state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ بِيَّةُ'));
state.updateEditingValue(const TextEditingValue(text: 'hello الْعَ بِيَّةُ '));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}الْعَ بِيَّةُ \u{200F}'));
});
testWidgets('Whitespace directionality formatter doesn\'t overwrite existing Arabic', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'testText');
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.blue,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
// Does not overwrite existing RLM or LRM characters
state.updateEditingValue(const TextEditingValue(text: 'hello \u{200F}ا'));
expect(state.currentTextEditingValue.text, equals('hello \u{200F}ا'));
state.updateEditingValue(const TextEditingValue(text: 'hello \u{200F}ا \u{200E}ا ا '));
expect(state.currentTextEditingValue.text, equals('hello \u{200F}ا \u{200E}ا ا \u{200F}'));
// Handles only directionality markers.
state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}'));
state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}\u{200E}\u{200F}\u{200E}\u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}\u{200E}\u{200F}\u{200E}\u{200F}'));
state.updateEditingValue(const TextEditingValue(text: '\u{200E}\u{200F}\u{200F}\u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}\u{200F}\u{200F}\u{200F}'));
});
testWidgets('Whitespace directionality formatter is not leaky Arabic', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'testText');
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.blue,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
// Can be passed through formatter repeatedly without leaking/growing.
state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
state.updateEditingValue(const TextEditingValue(text: 'hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
expect(state.currentTextEditingValue.text, equals('hello \u{200E}عَ \u{200F}عَ \u{200F}عَ \u{200F}'));
});
testWidgets('Whitespace directionality formatter emojis', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'testText');
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.blue,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
// Doesn't eat emojis
state.updateEditingValue(const TextEditingValue(text: '\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}'));
state.updateEditingValue(const TextEditingValue(text: '\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ 🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ \u{200F}🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}'));
});
testWidgets('Whitespace directionality formatter emojis', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'testText');
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: TextDirection.ltr,
child: FocusScope(
node: focusScopeNode,
autofocus: true,
child: EditableText(
backgroundCursorColor: Colors.blue,
controller: controller,
focusNode: focusNode,
maxLines: 1, // Sets text keyboard implicitly.
style: textStyle,
cursorColor: cursorColor,
),
),
),
),
);
await tester.tap(find.byType(EditableText));
await tester.showKeyboard(find.byType(EditableText));
controller.text = '';
await tester.idle();
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
expect(tester.testTextInput.editingState['text'], equals(''));
expect(state.wantKeepAlive, true);
// Doesn't eat emojis
state.updateEditingValue(const TextEditingValue(text: '\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}😀😁😂🤣😃 💑 👩‍❤️‍👩 👨‍❤️‍👨 💏 👩‍❤️‍💋‍👩 👨‍❤️‍💋‍👨 👪 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 \u{200F}'));
state.updateEditingValue(const TextEditingValue(text: '\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ 🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}'));
expect(state.currentTextEditingValue.text, equals('\u{200E}🇧🇼🇧🇷🇮🇴 🇻🇬🇧🇳wahhh!🇧🇬🇧🇫 🇧🇮🇰🇭عَ عَ \u{200F}🇨🇲 🇨🇦🇮🇨 🇨🇻🇧🇶 🇰🇾🇨🇫 🇹🇩🇨🇱 🇨🇳🇨🇽\u{200F}'));
});
}
class MockTextFormatter extends TextInputFormatter {
MockTextFormatter() : _counter = 0, log = <String>[];
MockTextFormatter() : formatCallCount = 0, log = <String>[];
int _counter;
int formatCallCount;
List<String> log;
TextEditingValue lastOldValue;
TextEditingValue lastNewValue;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
_counter++;
log.add('[$_counter]: ${oldValue.text}, ${newValue.text}');
lastOldValue = oldValue;
lastNewValue = newValue;
formatCallCount++;
log.add('[$formatCallCount]: ${oldValue.text}, ${newValue.text}');
TextEditingValue finalValue;
if (newValue.text.length < oldValue.text.length) {
finalValue = _handleTextDeletion(oldValue, newValue);
@ -4265,14 +4568,14 @@ class MockTextFormatter extends TextInputFormatter {
TextEditingValue _handleTextDeletion(
TextEditingValue oldValue, TextEditingValue newValue) {
final String result = 'a' * (_counter - 2);
log.add('[$_counter]: deleting $result');
final String result = 'a' * (formatCallCount - 2);
log.add('[$formatCallCount]: deleting $result');
return TextEditingValue(text: result);
}
TextEditingValue _formatText(TextEditingValue value) {
final String result = 'a' * _counter * 2;
log.add('[$_counter]: normal $result');
final String result = 'a' * formatCallCount * 2;
log.add('[$formatCallCount]: normal $result');
return TextEditingValue(text: result);
}
}