Unverified Commit ce0ec01f authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

squash commits (#68166)

parent 780752e8
......@@ -34,6 +34,9 @@ import 'ticker_provider.dart';
export 'package:flutter/rendering.dart' show SelectionChangedCause;
export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType, SmartQuotesType, SmartDashesType;
// Examples can assume:
// late TextInputFormatter usPhoneNumberFormatter;
/// Signature for the callback that reports when the user changes the selection
/// (including the cursor location).
typedef SelectionChangedCallback = void Function(TextSelection selection, SelectionChangedCause? cause);
......@@ -225,7 +228,7 @@ class TextEditingController extends ValueNotifier<TextEditingValue> {
/// change the controller's [value].
///
/// If the new selection if of non-zero length, or is outside the composing
/// range, the composing composing range is cleared.
/// range, the composing range is cleared.
set selection(TextSelection newSelection) {
if (!isSelectionWithinTextBounds(newSelection))
throw FlutterError('invalid text selection: $newSelection');
......@@ -272,6 +275,49 @@ class TextEditingController extends ValueNotifier<TextEditingValue> {
bool _isSelectionWithinComposingRange(TextSelection selection) {
return selection.start >= value.composing.start && selection.end <= value.composing.end;
}
List<TextInputFormatter>? _inputFormatters;
void _setInputFormatters(List<TextInputFormatter> newValue) {
// The setter does not take null values: if currentValue is null that means
// this is the first formatter list ever set, and we should not reformat.
final List<TextInputFormatter>? currentValue = _inputFormatters;
_inputFormatters = newValue;
if (newValue == currentValue || currentValue == null) {
return;
}
final Iterator<TextInputFormatter> oldFormatters = currentValue.iterator;
final Iterator<TextInputFormatter> newFormatters = newValue.iterator;
// Determining how many new input formatters need to be rerun:
//
// * The entire `newValue` list needs to be rerun if it has less formatters
// than the current list, or any of the new input formatter requests
// reformatting.
// * Otherwise, only apply the new input formatters whose index is larger
// than newValue.length.
bool needsReformat = currentValue.length > newValue.length;
while (!needsReformat && oldFormatters.moveNext() && newFormatters.moveNext()) {
if (newFormatters.current.shouldReformat(oldFormatters.current)) {
needsReformat = true;
}
}
TextEditingValue formatted = value;
if (needsReformat || oldFormatters.moveNext()) {
formatted = newValue.fold(
formatted,
(TextEditingValue v, TextInputFormatter formatter) => formatter.format(v),
);
} else {
while (newFormatters.moveNext()) {
formatted = newFormatters.current.format(formatted);
}
}
value = formatted;
}
}
/// Toolbar configuration for [EditableText].
......@@ -525,7 +571,7 @@ class EditableText extends StatefulWidget {
inputFormatters = maxLines == 1
? <TextInputFormatter>[
FilteringTextInputFormatter.singleLineFormatter,
...inputFormatters ?? const Iterable<TextInputFormatter>.empty(),
...?inputFormatters,
]
: inputFormatters,
showCursor = showCursor ?? !readOnly,
......@@ -1058,9 +1104,76 @@ class EditableText extends StatefulWidget {
/// {@template flutter.widgets.editableText.inputFormatters}
/// Optional input validation and formatting overrides.
///
/// Formatters are run in the provided order when the text input changes. When
/// this parameter changes, the new formatters will not be applied until the
/// next time the user inserts or deletes text.
/// Formatters are run in the provided order when the user changes the text
/// contained in the widget. They're not applied when the changes are
/// selection only, or not initiated by the user.
///
/// When this widget rebuilds, each input formatter in the new widget's
/// [inputFormatters] list checks the configuration of the input formatter
/// from the same location in the old [inputFormatters], to determine if the
/// new formatters need to be re-applied to the current [TextEditingValue] of
/// this widget.
///
/// {@tool snippet}
///
/// The following code uses a combination of 2 [TextInputFormatter]s and a
/// `UsPhoneNumberFormatter` (which simply adds parentheses and hypens), to
/// turn user input into a valid United States telephone number (for example,
/// (123)456-7890).
///
/// The combined effect of the 3 formatters is idempotent, meaning applying
/// them together to an already formatted value is a no-op. The
/// `UsPhoneNumberFormatter` is not idempotent, thus should not be used by
/// itself.
///
/// ```dart
/// class UsPhoneNumberFormatter extends TextInputFormatter {
/// const UsPhoneNumberFormatter();
///
/// @override
/// TextEditingValue format(TextEditingValue value) {
/// final int inputLength = value.text.length;
/// if (inputLength <= 3)
/// return value;
///
/// final StringBuffer newText = StringBuffer();
///
/// newText.write('(');
/// newText.write(value.text.substring(0, 3));
/// newText.write(')');
/// newText.write(value.text.substring(3, math.min(6, inputLength)));
///
/// if (inputLength > 6) {
/// newText.write('-');
/// newText.write(value.text.substring(6));
/// }
///
/// final int selectionOffset = value.selection.end <= 3 ? 1 : value.selection.end <= 6 ? 2 : 3;
/// return TextEditingValue(
/// text: newText.toString(),
/// selection: TextSelection.collapsed(offset: value.selection.end + selectionOffset),
/// );
/// }
///
/// @override
/// TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) => format(newValue);
///
/// @override
/// bool shouldReformat(TextInputFormatter oldFormatter) => oldFormatter is! UsPhoneNumberFormatter;
/// }
/// ```
///
/// ```dart
/// TextField(
/// inputFormatters: <TextInputFormatter>[
/// FilteringTextInputFormatter.digitsOnly,
/// LengthLimitingTextInputFormatter(10),
/// usPhoneNumberFormatter,
/// ],
/// )
/// ```
/// {@end-tool}
///
/// {@endtemplate}
final List<TextInputFormatter>? inputFormatters;
......@@ -1550,6 +1663,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller._setInputFormatters(widget.inputFormatters ?? const <TextInputFormatter>[]);
widget.controller.addListener(_didChangeTextEditingValue);
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
......@@ -1586,11 +1700,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override
void didUpdateWidget(EditableText oldWidget) {
beginBatchEdit();
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_didChangeTextEditingValue);
widget.controller.addListener(_didChangeTextEditingValue);
_updateRemoteEditingValueIfNeeded();
}
if (widget.controller.selection != oldWidget.controller.selection) {
_selectionOverlay?.update(_value);
......@@ -1636,6 +1750,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) {
_clipboardStatus?.update();
}
widget.controller._setInputFormatters(
widget.inputFormatters ?? const <TextInputFormatter>[]
);
endBatchEdit();
}
@override
......@@ -2225,7 +2344,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom;
}
late final _WhitespaceDirectionalityFormatter _whitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection);
_WhitespaceDirectionalityFormatter? _lastUsedWhitespaceFormatter;
_WhitespaceDirectionalityFormatter get _whitespaceFormatter {
final _WhitespaceDirectionalityFormatter? lastUsed = _lastUsedWhitespaceFormatter;
if (lastUsed != null && lastUsed._baseDirection == _textDirection)
return lastUsed;
return _lastUsedWhitespaceFormatter = _WhitespaceDirectionalityFormatter(textDirection: _textDirection);
}
void _formatAndSetValue(TextEditingValue value) {
// Only apply input formatters if the text has changed (including uncommited
......@@ -2241,18 +2366,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final bool selectionChanged = _value.selection != value.selection;
if (textChanged) {
value = widget.inputFormatters?.fold<TextEditingValue>(
final TextEditingValue formatted = widget.inputFormatters?.fold<TextEditingValue>(
value,
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
) ?? value;
// Always pass the text through the whitespace directionality formatter to
// maintain expected behavior with carets on trailing whitespace.
// TODO(LongCatIsLooong): The if statement here is for retaining the
// previous behavior. The input formatter logic will be updated in an
// upcoming PR.
if (widget.inputFormatters?.isNotEmpty ?? false)
value = _whitespaceFormatter.formatEditUpdate(_value, value);
value = _whitespaceFormatter.formatEditUpdate(_value, formatted);
}
// Put all optional user callback invocations in a batch edit to prevent
......
......@@ -628,4 +628,235 @@ void main() {
// cursor must be now at fourth position (right after the number 9)
expect(formatted.selection.baseOffset, equals(4));
});
group('provided formatters implement shouldReformat correctly', () {
test('length limiting formatter', () {
expect(
LengthLimitingTextInputFormatter(-1).shouldReformat(LengthLimitingTextInputFormatter(null)),
isFalse,
);
expect(
LengthLimitingTextInputFormatter(null).shouldReformat(LengthLimitingTextInputFormatter(-1)),
isFalse,
);
expect(
LengthLimitingTextInputFormatter(null).shouldReformat(LengthLimitingTextInputFormatter(null)),
isFalse,
);
expect(
LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(3)),
isFalse,
);
// We're relaxing the length constraint. No reformatting needed.
expect(
LengthLimitingTextInputFormatter(-1).shouldReformat(LengthLimitingTextInputFormatter(3)),
isFalse,
);
// We're relaxing the length constraint. No reformatting needed.
expect(
LengthLimitingTextInputFormatter(4).shouldReformat(LengthLimitingTextInputFormatter(3)),
isFalse,
);
expect(
LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(4)),
isTrue,
);
expect(
LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(null)),
isTrue,
);
expect(
LengthLimitingTextInputFormatter(3).shouldReformat(LengthLimitingTextInputFormatter(-1)),
isTrue,
);
});
test('FliteringTextInputFormatter', () {
expect(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b'),
),
isFalse,
);
expect(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat(
FilteringTextInputFormatter('a', allow: true, replacementString: 'c'),
),
isTrue,
);
expect(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat(
FilteringTextInputFormatter('a', allow: false, replacementString: 'b'),
),
isTrue,
);
expect(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat(
FilteringTextInputFormatter('c', allow: true, replacementString: 'b'),
),
isTrue,
);
expect(
FilteringTextInputFormatter('a', allow: true, replacementString: 'b').shouldReformat(
FilteringTextInputFormatter('c', allow: true),
),
isTrue,
);
});
});
group('provided formatters do not further modify a formatted value', () {
// Framework-provided TextInputFormatters must be idempotent in order to be
// used alone.
void verifyFormatterIdempotency(
TextInputFormatter formatter,
TextEditingValue input,
) {
final TextEditingValue formatted = formatter.format(input);
expect(formatter.format(formatted), formatted);
}
setUp(() {
// a1b(2c3
// d4)e5f6
// where the parentheses are the selection range.
testNewValue = const TextEditingValue(
text: 'a1b2c3\nd4e5f6',
selection: TextSelection(
baseOffset: 3,
extentOffset: 9,
),
);
});
test('FliteringTextInputFormatter with replacementString', () {
const TextEditingValue selectedIntoTheWoods = TextEditingValue(
text: 'Into the Woods',
selection: TextSelection(baseOffset: 11, extentOffset: 14),
);
for (final Pattern p in <Pattern>['o', RegExp('o+')]) {
verifyFormatterIdempotency(
FilteringTextInputFormatter(p, allow: true, replacementString: '*'),
selectedIntoTheWoods,
);
verifyFormatterIdempotency(
FilteringTextInputFormatter(p, allow: false, replacementString: '*'),
selectedIntoTheWoods,
);
}
});
test('single line formatter', () {
verifyFormatterIdempotency(
FilteringTextInputFormatter.singleLineFormatter,
testNewValue,
);
});
test('digits only formatter', () {
verifyFormatterIdempotency(
FilteringTextInputFormatter.digitsOnly,
testNewValue,
);
});
test('length limiting formatter', () {
verifyFormatterIdempotency(
LengthLimitingTextInputFormatter(5),
testNewValue,
);
});
});
group('CompositeTextInputFormatter', () {
test('combine effects, in provided order', () {
final CompositeTextInputFormatter formatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(3),
]
);
expect(formatter.format(const TextEditingValue(text: 'aab')).text, 'aab');
expect(
formatter.formatEditUpdate(const TextEditingValue(text: 'aaa'), const TextEditingValue(text: 'aab')).text,
'aaa',
);
});
test('anyChildNeedsReformat', () {
final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(3),
]
);
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(1),
]
);
expect(newFormatter.shouldReformat(newFormatter), isFalse);
expect(oldFormatter.shouldReformat(oldFormatter), isFalse);
expect(newFormatter.shouldReformat(oldFormatter), isTrue);
});
test('neverReformat', () {
final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(3),
]
);
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(1),
],
shouldReformatPredicate: CompositeTextInputFormatter.neverReformat,
);
expect(newFormatter.shouldReformat(newFormatter), isFalse);
expect(oldFormatter.shouldReformat(oldFormatter), isFalse);
expect(newFormatter.shouldReformat(oldFormatter), isFalse);
});
test('alwaysReformat', () {
final CompositeTextInputFormatter oldFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(3),
]
);
final CompositeTextInputFormatter newFormatter = CompositeTextInputFormatter(
<TextInputFormatter>[
FilteringTextInputFormatter.allow(RegExp(r'[a\*]'), replacementString: '**'),
LengthLimitingTextInputFormatter(999),
],
shouldReformatPredicate: CompositeTextInputFormatter.alwaysReformat,
);
expect(newFormatter.shouldReformat(newFormatter), isTrue);
expect(oldFormatter.shouldReformat(oldFormatter), isFalse);
expect(newFormatter.shouldReformat(oldFormatter), isTrue);
});
});
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
void main() {
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Node');
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
const Color backgroundColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
late TextEditingController defaultController;
group('didUpdateWidget', () {
final _AppendingFormatter appendingFormatter = _AppendingFormatter();
Widget build({
TextDirection textDirection = TextDirection.ltr,
List<TextInputFormatter>? formatters,
TextEditingController? controller,
}) {
return MediaQuery(
data: const MediaQueryData(devicePixelRatio: 1.0),
child: Directionality(
textDirection: textDirection,
child: EditableText(
backgroundCursorColor: backgroundColor,
controller: controller ?? defaultController,
maxLines: null, // Remove the builtin newline formatter.
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
inputFormatters: formatters,
),
),
);
}
testWidgets('EditableText only reformats when needed', (WidgetTester tester) async {
appendingFormatter.needsReformat = false;
defaultController = TextEditingController(text: 'initialText');
String previousText = defaultController.text;
// Initial build, do not apply formatters.
await tester.pumpWidget(build());
expect(defaultController.text, previousText);
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
LengthLimitingTextInputFormatter(null),
appendingFormatter,
]));
expect(defaultController.text, contains(previousText + 'a'));
previousText = defaultController.text;
// Change the first formatter.
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
LengthLimitingTextInputFormatter(1000),
appendingFormatter,
]));
// Reformat since the length formatter changed and it becomes more
// strict (null -> 1000).
expect(defaultController.text, contains(previousText + 'a'));
previousText = defaultController.text;
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
LengthLimitingTextInputFormatter(2000),
appendingFormatter,
]));
// No reformat needed since the length formatter relaxed its constraint
// (1000 -> 2000).
expect(defaultController.text, previousText);
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
appendingFormatter,
]));
// Reformat since we reduced the number of new formatters.
expect(defaultController.text, previousText + 'a');
previousText = defaultController.text;
// Now the the appending formatter always requests a reformat when
// didUpdateWidget is called.
appendingFormatter.needsReformat = true;
await tester.pumpWidget(build(formatters: <TextInputFormatter>[
appendingFormatter,
]));
// Reformat since appendingFormatter now always requests a rerun.
expect(defaultController.text, contains(previousText + 'a'));
previousText = defaultController.text;
});
testWidgets(
'Changing the controller along with the formatter does not reformat',
(WidgetTester tester) async {
// This test verifies that the `shouldReformat` predicate is run against
// the previous formatter associated with the *TextEditingController*,
// instead of the one associated with the widget, to avoid unnecessary
// rebuilds.
final TextEditingController controller1 = TextEditingController(text: 'shorttxt');
final TextEditingController controller2 = TextEditingController(text: 'looooong text');
final Widget editableText1 = build(
controller: controller1,
formatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(controller1.text.length)],
);
final Widget editableText2 = build(
controller: controller2,
formatters: <TextInputFormatter>[LengthLimitingTextInputFormatter(controller2.text.length)],
);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: Column(children: <Widget>[editableText1, editableText2]),
));
// The 2 input fields swap places. The input formatters should not rerun.
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: Column(children: <Widget>[editableText2, editableText1]),
));
expect(controller1.text, 'shorttxt');
expect(controller2.text, 'looooong text');
});
});
}
// A TextInputFormatter that appends 'a' to the current editing value every time
// it runs.
class _AppendingFormatter extends TextInputFormatter {
bool needsReformat = true;
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
return newValue.copyWith(text: newValue.text + 'a');
}
@override
bool shouldReformat(TextInputFormatter oldFormatter) => needsReformat;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment