Commit f65fea8e authored by xster's avatar xster Committed by GitHub

Create a text validator/formatter mechanism (#9535)

* Add a text formatter interface used by EditingText. Provide some default implementations.

* self nits

* Handle -1 selection offsets

* review notes

* simplify regular expression

* Add whitelisting formatters. Use a custom phone number formatter in text demo.

* review notes

* not being able to addAll(null) is pretty annoying

* review notes

* partial tests

* Add tests

* didn’t end up needing mockito

* move to services
parent b717963b
......@@ -5,6 +5,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TextFormFieldDemo extends StatefulWidget {
const TextFormFieldDemo({ Key key }) : super(key: key);
......@@ -36,6 +37,7 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
bool _formWasEdited = false;
final GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
final GlobalKey<FormFieldState<String>> _passwordFieldKey = new GlobalKey<FormFieldState<String>>();
final _UsNumberTextInputFormatter _phoneNumberFormatter = new _UsNumberTextInputFormatter();
void _handleSubmitted() {
final FormState form = _formKey.currentState;
if (!form.validate()) {
......@@ -59,9 +61,9 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
String _validatePhoneNumber(String value) {
_formWasEdited = true;
final RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$');
final RegExp phoneExp = new RegExp(r'^\(\d\d\d\) \d\d\d\-\d\d\d\d$');
if (!phoneExp.hasMatch(value))
return '###-###-#### - Please enter a valid phone number.';
return '(###) ###-#### - Please enter a valid US phone number.';
return null;
}
......@@ -131,6 +133,12 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
keyboardType: TextInputType.phone,
onSaved: (String value) { person.phoneNumber = value; },
validator: _validatePhoneNumber,
// TextInputFormatters are applied in sequence.
inputFormatters: <TextInputFormatter> [
WhitelistingTextInputFormatter.digitsOnly,
// Fit the validating format.
_phoneNumberFormatter,
],
),
new TextFormField(
decoration: const InputDecoration(
......@@ -184,3 +192,40 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
);
}
}
/// Format incoming numeric text to fit the format of (###) ###-#### ##...
class _UsNumberTextInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue
) {
final int newTextLength = newValue.text.length;
int selectionIndex = newValue.selection.end;
int usedSubstringIndex = 0;
final StringBuffer newText = new StringBuffer();
if (newTextLength >= 1) {
newText.write('(');
if (newValue.selection.end >= 1) selectionIndex++;
}
if (newTextLength >= 4) {
newText.write(newValue.text.substring(0, usedSubstringIndex = 3) + ') ');
if (newValue.selection.end >= 3) selectionIndex += 2;
}
if (newTextLength >= 7) {
newText.write(newValue.text.substring(3, usedSubstringIndex = 6) + '-');
if (newValue.selection.end >= 6) selectionIndex++;
}
if (newTextLength >= 11) {
newText.write(newValue.text.substring(6, usedSubstringIndex = 10) + ' ');
if (newValue.selection.end >= 10) selectionIndex++;
}
// Dump the rest.
if (newTextLength >= usedSubstringIndex)
newText.write(newValue.text.substring(usedSubstringIndex));
return new TextEditingValue(
text: newText.toString(),
selection: new TextSelection.collapsed(offset: selectionIndex),
);
}
}
......@@ -31,5 +31,6 @@ export 'src/services/system_chrome.dart';
export 'src/services/system_navigator.dart';
export 'src/services/system_sound.dart';
export 'src/services/text_editing.dart';
export 'src/services/text_formatter.dart';
export 'src/services/text_input.dart';
export 'src/services/url_launcher.dart';
......@@ -75,6 +75,7 @@ class TextField extends StatefulWidget {
this.maxLines: 1,
this.onChanged,
this.onSubmitted,
this.inputFormatters,
}) : super(key: key);
/// Controls the text being edited.
......@@ -141,6 +142,10 @@ class TextField extends StatefulWidget {
/// field.
final ValueChanged<String> onSubmitted;
/// Optional input validation and formatting overrides. Formatters are run
/// in the provided order when the text input changes.
final List<TextInputFormatter> inputFormatters;
@override
_TextFieldState createState() => new _TextFieldState();
......@@ -223,6 +228,7 @@ class _TextFieldState extends State<TextField> {
selectionControls: materialTextSelectionControls,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
inputFormatters: widget.inputFormatters,
),
);
......
......@@ -43,6 +43,7 @@ class TextFormField extends FormField<String> {
int maxLines: 1,
FormFieldSetter<String> onSaved,
FormFieldValidator<String> validator,
List<TextInputFormatter> inputFormatters,
}) : super(
key: key,
initialValue: controller != null ? controller.value.text : '',
......@@ -59,6 +60,7 @@ class TextFormField extends FormField<String> {
obscureText: obscureText,
maxLines: maxLines,
onChanged: field.onChanged,
inputFormatters: inputFormatters,
);
},
);
......
......@@ -195,4 +195,20 @@ class TextSelection extends TextRange {
affinity.hashCode,
isDirectional.hashCode
);
/// Creates a new [TextSelection] based on the current selection, with the
/// provided parameters overridden.
TextSelection copyWith({
int baseOffset,
int extentOffset,
TextAffinity affinity,
bool isDirectional,
}) {
return new TextSelection(
baseOffset: baseOffset ?? this.baseOffset,
extentOffset: extentOffset ?? this.extentOffset,
affinity: affinity ?? this.affinity,
isDirectional: isDirectional ?? this.isDirectional,
);
}
}
// Copyright 2017 The Chromium 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/services.dart';
/// A [TextInputFormatter] can be optionally injected into an [EditableText]
/// to provide as-you-type validation and formatting of the text being edited.
///
/// Text modification should only be applied when text is being committed by the
/// IME and not on text under composition (i.e. when
/// [TextEditingValue.composing] is collapsed).
///
/// Concrete implementations [BlacklistingTextInputFormatter], which removes
/// blacklisted characters upon edit commit, and
/// [WhitelistingTextInputFormatter], which only allows entries of whitelisted
/// characters, are provided.
///
/// To create custom formatters, extend the [TextInputFormatter] class and
/// implement the [formatEditUpdate] method.
///
/// See also:
///
/// * [EditableText] on which the formatting apply.
/// * [BlacklistingTextInputFormatter], a provided formatter for blacklisting
/// characters.
/// * [WhitelistingTextInputFormatter], a provided formatter for whitelisting
/// characters.
abstract class TextInputFormatter {
/// Called when text is being typed or cut/copy/pasted in the [EditableText].
///
/// You can override the resulting text based on the previous text value and
/// the incoming new text value.
///
/// When formatters are chained, `oldValue` reflects the initial value of
/// [TextEditingValue] at the beginning of the chain.
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue
);
/// A shorthand to creating a custom [TextInputFormatter] which formats
/// incoming text input changes with the given function.
static TextInputFormatter withFunction(
TextInputFormatFunction formatFunction
) {
return new _SimpleTextInputFormatter(formatFunction);
}
}
/// Function signature expected for creating custom [TextInputFormatter]
/// shorthands via [TextInputFormatter.withFunction];
typedef TextEditingValue TextInputFormatFunction(
TextEditingValue oldValue,
TextEditingValue newValue,
);
/// Wiring for [TextInputFormatter.withFunction].
class _SimpleTextInputFormatter extends TextInputFormatter {
_SimpleTextInputFormatter(this.formatFunction) :
assert(formatFunction != null);
final TextInputFormatFunction formatFunction;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue
) {
return formatFunction(oldValue, newValue);
}
}
/// A [TextInputFormatter] that prevents the insertion of blacklisted
/// characters patterns.
///
/// Instances of blacklisted characters found in the new [TextEditingValue]s
/// will be replaced with the [replacementString] which defaults to ``.
///
/// Since this formatter only removes characters from the text, it attempts to
/// preserve the existing [TextEditingValue.selection] to values it would now
/// fall at with the removed characters.
///
/// See also:
///
/// * [TextInputFormatter].
/// * [WhitelistingTextInputFormatter].
class BlacklistingTextInputFormatter extends TextInputFormatter {
BlacklistingTextInputFormatter(
this.blacklistedPattern,
{
this.replacementString: '',
}
) : assert(blacklistedPattern != null);
/// A [Pattern] to match and replace incoming [TextEditingValue]s.
final Pattern blacklistedPattern;
/// String used to replace found patterns.
final String replacementString;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return substring.replaceAll(blacklistedPattern, replacementString);
},
);
}
/// A [BlacklistingTextInputFormatter] that forces input to be a single line.
static final BlacklistingTextInputFormatter singleLineFormatter
= new BlacklistingTextInputFormatter(new RegExp(r'\n'));
}
/// A [TextInputFormatter] that allows only the insertion of whitelisted
/// characters patterns.
///
/// Since this formatter only removes characters from the text, it attempts to
/// preserve the existing [TextEditingValue.selection] to values it would now
/// fall at with the removed characters.
///
/// See also:
///
/// * [TextInputFormatter].
/// * [BlacklistingTextInputFormatter].
class WhitelistingTextInputFormatter extends TextInputFormatter {
WhitelistingTextInputFormatter(this.whitelistedPattern) :
assert(whitelistedPattern != null);
/// A [Pattern] to extract all instances of allowed characters.
///
/// [RegExp] with multiple groups is not supported.
final Pattern whitelistedPattern;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
return _selectionAwareTextManipulation(
newValue,
(String substring) {
return whitelistedPattern
.allMatches(substring)
.map((Match match) => match.group(0))
.join();
} ,
);
}
/// A [WhitelistingTextInputFormatter] that takes in digits `[0-9]` only.
static final WhitelistingTextInputFormatter digitsOnly
= new WhitelistingTextInputFormatter(new RegExp(r'\d+'));
}
TextEditingValue _selectionAwareTextManipulation(
TextEditingValue value,
String substringManipulation(String substring),
) {
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;
manipulatedSelection = value.selection.copyWith(
baseOffset: beforeSelection.length,
extentOffset: beforeSelection.length + inSelection.length,
);
}
return new TextEditingValue(
text: manipulatedText,
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
composing: manipulatedText == value.text
? value.composing
: TextRange.empty,
);
}
......@@ -117,7 +117,7 @@ class EditableText extends StatefulWidget {
///
/// The [controller], [focusNode], [style], and [cursorColor] arguments must
/// not be null.
const EditableText({
EditableText({
Key key,
@required this.controller,
@required this.focusNode,
......@@ -133,6 +133,7 @@ class EditableText extends StatefulWidget {
this.keyboardType,
this.onChanged,
this.onSubmitted,
List<TextInputFormatter> inputFormatters,
}) : assert(controller != null),
assert(focusNode != null),
assert(obscureText != null),
......@@ -140,6 +141,12 @@ class EditableText extends StatefulWidget {
assert(cursorColor != null),
assert(maxLines != null),
assert(autofocus != null),
inputFormatters = maxLines == 1
? (
<TextInputFormatter>[BlacklistingTextInputFormatter.singleLineFormatter]
..addAll(inputFormatters ?? const Iterable<TextInputFormatter>.empty())
)
: inputFormatters,
super(key: key);
/// Controls the text being edited.
......@@ -197,6 +204,10 @@ class EditableText extends StatefulWidget {
/// Called when the user indicates that they are done editing the text in the field.
final ValueChanged<String> onSubmitted;
/// Optional input validation and formatting overrides. Formatters are run
/// in the provided order when the text input changes.
final List<TextInputFormatter> inputFormatters;
@override
EditableTextState createState() => new EditableTextState();
}
......@@ -266,7 +277,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
if (value.text != _value.text)
_hideSelectionOverlayIfNeeded();
_lastKnownRemoteTextEditingValue = value;
_value = value;
_formatAndSetValue(value);
if (widget.onChanged != null)
widget.onChanged(value.text);
}
......@@ -396,10 +407,21 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
void _handleSelectionOverlayChanged(TextEditingValue value, Rect caretRect) {
assert(!value.composing.isValid); // composing range must be empty while selecting.
_value = value;
_formatAndSetValue(value);
_scrollController.jumpTo(_getScrollOffsetForCaret(caretRect));
}
void _formatAndSetValue(TextEditingValue value) {
if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
for (TextInputFormatter formatter in widget.inputFormatters)
value = formatter.formatEditUpdate(_value, value);
_value = value;
_updateRemoteEditingValueIfNeeded();
} else {
_value = value;
}
}
/// Whether the blinking cursor is actually visible at this precise moment
/// (it's hidden half the time, since it blinks).
@visibleForTesting
......
......@@ -972,4 +972,114 @@ void main() {
await tester.idle();
expect(tester.testTextInput.editingState['text'], equals(''));
});
testWidgets(
'Cannot enter new lines onto single line TextField',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(new Material(
child: new TextField(controller: textController, decoration: null),
));
await tester.enterText(find.byType(TextField), 'abc\ndef');
expect(textController.text, 'abcdef');
}
);
testWidgets(
'Injected formatters are chained',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(new Material(
child: new TextField(
controller: textController,
decoration: null,
inputFormatters: <TextInputFormatter> [
new BlacklistingTextInputFormatter(
new RegExp(r'[a-z]'),
replacementString: '#',
),
],
),
));
await tester.enterText(find.byType(TextField), 'a一b二c三\nd四e五f六');
// The default single line formatter replaces \n with empty string.
expect(textController.text, '#一#二#三#四#五#六');
}
);
testWidgets(
'Chained formatters are in sequence',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(new Material(
child: new TextField(
controller: textController,
decoration: null,
maxLines: 2,
inputFormatters: <TextInputFormatter> [
new BlacklistingTextInputFormatter(
new RegExp(r'[a-z]'),
replacementString: '12\n',
),
new WhitelistingTextInputFormatter(new RegExp(r'\n[0-9]')),
],
),
));
await tester.enterText(find.byType(TextField), 'a1b2c3');
// The first formatter turns it into
// 12\n112\n212\n3
// The second formatter turns it into
// \n1\n2\n3
// Multiline is allowed since maxLine != 1.
expect(textController.text, '\n1\n2\n3');
}
);
testWidgets(
'Pasted values are formatted',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
Widget builder() {
return overlay(new Center(
child: new Material(
child: new TextField(
controller: textController,
decoration: null,
inputFormatters: <TextInputFormatter> [
WhitelistingTextInputFormatter.digitsOnly,
],
),
),
));
}
await tester.pumpWidget(builder());
await tester.enterText(find.byType(TextField), 'a1b\n2c3');
expect(textController.text, '123');
await tester.pumpWidget(builder());
await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2')));
await tester.pumpWidget(builder());
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints =
renderEditable.getEndpointsForSelection(textController.selection);
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());
Clipboard.setData(const ClipboardData(text: '一4二\n5三6'));
await tester.tap(find.text('PASTE'));
await tester.pumpWidget(builder());
// Puts 456 before the 2 in 123.
expect(textController.text, '145623');
}
);
}
// Copyright 2017 The Chromium 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/services.dart';
import 'package:flutter/widgets.dart';
void main() {
TextEditingValue testOldValue;
TextEditingValue testNewValue;
test('withFunction wraps formatting function', () {
testOldValue = const TextEditingValue();
testNewValue = const TextEditingValue();
TextEditingValue calledOldValue;
TextEditingValue calledNewValue;
final TextInputFormatter formatterUnderTest = TextInputFormatter.withFunction(
(TextEditingValue oldValue, TextEditingValue newValue) {
calledOldValue = oldValue;
calledNewValue = newValue;
}
);
formatterUnderTest.formatEditUpdate(testOldValue, testNewValue);
expect(calledOldValue, equals(testOldValue));
expect(calledNewValue, equals(testNewValue));
});
group('test provided formatters', () {
setUp(() {
// a1b(2c3
// d4)e5f6
// where the parentheses are the selection range.
testNewValue = const TextEditingValue(
text: 'a1b2c3\nd4e5f6',
selection: const TextSelection(
baseOffset: 3,
extentOffset: 9,
),
);
});
test('test blacklisting formatter', () {
final TextEditingValue actualValue =
new BlacklistingTextInputFormatter(new RegExp(r'[a-z]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(23
// 4)56
expect(actualValue, const TextEditingValue(
text: '123\n456',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 5,
),
));
});
test('test single line formatter', () {
final TextEditingValue actualValue =
BlacklistingTextInputFormatter.singleLineFormatter
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6',
selection: const TextSelection(
baseOffset: 3,
extentOffset: 8,
),
));
});
test('test whitelisting formatter', () {
final TextEditingValue actualValue =
new WhitelistingTextInputFormatter(new RegExp(r'[a-c]'))
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// ab(c)
expect(actualValue, const TextEditingValue(
text: 'abc',
selection: const TextSelection(
baseOffset: 2,
extentOffset: 3,
),
));
});
test('test digits only formatter', () {
final TextEditingValue actualValue =
WhitelistingTextInputFormatter.digitsOnly
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// 1(234)56
expect(actualValue, const TextEditingValue(
text: '123456',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 4,
),
));
});
});
}
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