diff --git a/examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart b/examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart index 4f946d8ad3..64c5b35e7b 100644 --- a/examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart @@ -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 { bool _formWasEdited = false; final GlobalKey _formKey = new GlobalKey(); final GlobalKey> _passwordFieldKey = new GlobalKey>(); + final _UsNumberTextInputFormatter _phoneNumberFormatter = new _UsNumberTextInputFormatter(); void _handleSubmitted() { final FormState form = _formKey.currentState; if (!form.validate()) { @@ -59,9 +61,9 @@ class TextFormFieldDemoState extends State { 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 { keyboardType: TextInputType.phone, onSaved: (String value) { person.phoneNumber = value; }, validator: _validatePhoneNumber, + // TextInputFormatters are applied in sequence. + inputFormatters: [ + WhitelistingTextInputFormatter.digitsOnly, + // Fit the validating format. + _phoneNumberFormatter, + ], ), new TextFormField( decoration: const InputDecoration( @@ -184,3 +192,40 @@ class TextFormFieldDemoState extends State { ); } } + +/// 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), + ); + } +} diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart index 956a7b022b..90fc1a8460 100644 --- a/packages/flutter/lib/services.dart +++ b/packages/flutter/lib/services.dart @@ -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'; diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index f9a90b1c1d..a0e219db11 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.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 onSubmitted; + /// Optional input validation and formatting overrides. Formatters are run + /// in the provided order when the text input changes. + final List inputFormatters; + @override _TextFieldState createState() => new _TextFieldState(); @@ -223,6 +228,7 @@ class _TextFieldState extends State { selectionControls: materialTextSelectionControls, onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, + inputFormatters: widget.inputFormatters, ), ); diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index ee08e6e6fc..8ef46d915f 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -43,6 +43,7 @@ class TextFormField extends FormField { int maxLines: 1, FormFieldSetter onSaved, FormFieldValidator validator, + List inputFormatters, }) : super( key: key, initialValue: controller != null ? controller.value.text : '', @@ -59,6 +60,7 @@ class TextFormField extends FormField { obscureText: obscureText, maxLines: maxLines, onChanged: field.onChanged, + inputFormatters: inputFormatters, ); }, ); diff --git a/packages/flutter/lib/src/services/text_editing.dart b/packages/flutter/lib/src/services/text_editing.dart index 1247f96633..bfe1d96abb 100644 --- a/packages/flutter/lib/src/services/text_editing.dart +++ b/packages/flutter/lib/src/services/text_editing.dart @@ -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, + ); + } } diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart new file mode 100644 index 0000000000..77773ab2eb --- /dev/null +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -0,0 +1,194 @@ +// 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, + ); +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 50387d1590..de7b6a5f19 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 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 + ? ( + [BlacklistingTextInputFormatter.singleLineFormatter] + ..addAll(inputFormatters ?? const Iterable.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 onSubmitted; + /// Optional input validation and formatting overrides. Formatters are run + /// in the provided order when the text input changes. + final List inputFormatters; + @override EditableTextState createState() => new EditableTextState(); } @@ -266,7 +277,7 @@ class EditableTextState extends State 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 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 diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 0bc4270ed6..803c61ba91 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -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: [ + 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: [ + 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: [ + 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 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'); + } + ); } diff --git a/packages/flutter/test/widgets/text_formatter_test.dart b/packages/flutter/test/widgets/text_formatter_test.dart new file mode 100644 index 0000000000..80382cbd79 --- /dev/null +++ b/packages/flutter/test/widgets/text_formatter_test.dart @@ -0,0 +1,112 @@ +// 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, + ), + )); + }); + }); +}