Commit ea679171 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add Material character counter to TextField. (#12648)

This adds an optional character counter and maxLength parameter to the TextField, as described in the Material Design Spec.

The counter text and style in the input decorator may be specified, but will default to the "right thing" if not specified, where the "right thing" is a counter that looks like "3 / 10" (if there are three characters entered into a field where maxLength is set to 10).

To limit the number of characters entered, I created a LengthLimitingTextFormatter that will limit the number of characters (Unicode runes) in the input, which can be used independently. The formatter is applied after the other formatters supplied (if any). Even if there is no decorator, the text field will limit the number of characters input if maxLength is set.

If maxLengthEnforced is set to false (it defaults to true), then the max length will not be enforced. In that case, if the text exceeds the length, then the counter will turn red, and it will make the divider turn red.
parent c3e04961
...@@ -54,6 +54,8 @@ class InputDecoration { ...@@ -54,6 +54,8 @@ class InputDecoration {
this.prefixStyle, this.prefixStyle,
this.suffixText, this.suffixText,
this.suffixStyle, this.suffixStyle,
this.counterText,
this.counterStyle,
}) : isCollapsed = false; }) : isCollapsed = false;
/// Creates a decoration that is the same size as the input field. /// Creates a decoration that is the same size as the input field.
...@@ -78,7 +80,9 @@ class InputDecoration { ...@@ -78,7 +80,9 @@ class InputDecoration {
prefixText = null, prefixText = null,
prefixStyle = null, prefixStyle = null,
suffixText = null, suffixText = null,
suffixStyle = null; suffixStyle = null,
counterText = null,
counterStyle = null;
/// An icon to show before the input field. /// An icon to show before the input field.
/// ///
...@@ -189,6 +193,17 @@ class InputDecoration { ...@@ -189,6 +193,17 @@ class InputDecoration {
/// If null, defaults to the [hintStyle]. /// If null, defaults to the [hintStyle].
final TextStyle suffixStyle; final TextStyle suffixStyle;
/// Optional text to place below the line as a character count.
///
/// Rendered using [counterStyle]. Uses [helperStyle] if [counterStyle] is
/// null.
final String counterText;
/// The style to use for the [counterText].
///
/// If null, defaults to the [helperStyle].
final TextStyle counterStyle;
/// Creates a copy of this input decoration but with the given fields replaced /// Creates a copy of this input decoration but with the given fields replaced
/// with the new values. /// with the new values.
/// ///
...@@ -209,6 +224,8 @@ class InputDecoration { ...@@ -209,6 +224,8 @@ class InputDecoration {
TextStyle prefixStyle, TextStyle prefixStyle,
String suffixText, String suffixText,
TextStyle suffixStyle, TextStyle suffixStyle,
String counterText,
TextStyle counterStyle,
}) { }) {
return new InputDecoration( return new InputDecoration(
icon: icon ?? this.icon, icon: icon ?? this.icon,
...@@ -226,6 +243,8 @@ class InputDecoration { ...@@ -226,6 +243,8 @@ class InputDecoration {
prefixStyle: prefixStyle ?? this.prefixStyle, prefixStyle: prefixStyle ?? this.prefixStyle,
suffixText: suffixText ?? this.suffixText, suffixText: suffixText ?? this.suffixText,
suffixStyle: suffixStyle ?? this.suffixStyle, suffixStyle: suffixStyle ?? this.suffixStyle,
counterText: counterText ?? this.counterText,
counterStyle: counterStyle ?? this.counterStyle,
); );
} }
...@@ -251,7 +270,9 @@ class InputDecoration { ...@@ -251,7 +270,9 @@ class InputDecoration {
&& typedOther.prefixText == prefixText && typedOther.prefixText == prefixText
&& typedOther.prefixStyle == prefixStyle && typedOther.prefixStyle == prefixStyle
&& typedOther.suffixText == suffixText && typedOther.suffixText == suffixText
&& typedOther.suffixStyle == suffixStyle; && typedOther.suffixStyle == suffixStyle
&& typedOther.counterText == counterText
&& typedOther.counterStyle == counterStyle;
} }
@override @override
...@@ -273,6 +294,8 @@ class InputDecoration { ...@@ -273,6 +294,8 @@ class InputDecoration {
prefixStyle, prefixStyle,
suffixText, suffixText,
suffixStyle, suffixStyle,
counterText,
counterStyle,
); );
} }
...@@ -303,6 +326,10 @@ class InputDecoration { ...@@ -303,6 +326,10 @@ class InputDecoration {
description.add('suffixText: $suffixText'); description.add('suffixText: $suffixText');
if (suffixStyle != null) if (suffixStyle != null)
description.add('suffixStyle: $suffixStyle'); description.add('suffixStyle: $suffixStyle');
if (counterText != null)
description.add('counterText: $counterText');
if (counterStyle != null)
description.add('counterStyle: $counterStyle');
return 'InputDecoration(${description.join(', ')})'; return 'InputDecoration(${description.join(', ')})';
} }
} }
...@@ -398,7 +425,7 @@ class InputDecorator extends StatelessWidget { ...@@ -398,7 +425,7 @@ class InputDecorator extends StatelessWidget {
return themeData.hintColor; return themeData.hintColor;
} }
Widget _buildContent(Color borderColor, double topPadding, bool isDense, Widget inputChild, double subTextHeight) { Widget _buildContent(Color borderColor, double topPadding, bool isDense, Widget inputChild) {
if (decoration.hideDivider) { if (decoration.hideDivider) {
return new Container( return new Container(
padding: new EdgeInsets.only(top: topPadding, bottom: _kNormalPadding), padding: new EdgeInsets.only(top: topPadding, bottom: _kNormalPadding),
...@@ -434,6 +461,7 @@ class InputDecorator extends StatelessWidget { ...@@ -434,6 +461,7 @@ class InputDecorator extends StatelessWidget {
final String labelText = decoration.labelText; final String labelText = decoration.labelText;
final String helperText = decoration.helperText; final String helperText = decoration.helperText;
final String counterText = decoration.counterText;
final String hintText = decoration.hintText; final String hintText = decoration.hintText;
final String errorText = decoration.errorText; final String errorText = decoration.errorText;
...@@ -446,12 +474,13 @@ class InputDecorator extends StatelessWidget { ...@@ -446,12 +474,13 @@ class InputDecorator extends StatelessWidget {
final TextStyle baseStyle = this.baseStyle ?? themeData.textTheme.subhead; final TextStyle baseStyle = this.baseStyle ?? themeData.textTheme.subhead;
final TextStyle hintStyle = decoration.hintStyle ?? baseStyle.copyWith(color: themeData.hintColor); final TextStyle hintStyle = decoration.hintStyle ?? baseStyle.copyWith(color: themeData.hintColor);
final TextStyle helperStyle = decoration.helperStyle ?? themeData.textTheme.caption.copyWith(color: themeData.hintColor);
final TextStyle counterStyle = decoration.counterStyle ?? helperStyle;
final TextStyle subtextStyle = errorText != null final TextStyle subtextStyle = errorText != null
? decoration.errorStyle ?? themeData.textTheme.caption.copyWith(color: themeData.errorColor) ? decoration.errorStyle ?? themeData.textTheme.caption.copyWith(color: themeData.errorColor)
: decoration.helperStyle ?? themeData.textTheme.caption.copyWith(color: themeData.hintColor); : helperStyle;
final double entryTextHeight = baseStyle.fontSize * textScaleFactor; final double entryTextHeight = baseStyle.fontSize * textScaleFactor;
final double subTextHeight = subtextStyle.fontSize * textScaleFactor;
double topPadding = isCollapsed ? 0.0 : (isDense ? _kDenseTopPadding : _kNormalTopPadding); double topPadding = isCollapsed ? 0.0 : (isDense ? _kDenseTopPadding : _kNormalTopPadding);
...@@ -545,15 +574,18 @@ class InputDecorator extends StatelessWidget { ...@@ -545,15 +574,18 @@ class InputDecorator extends StatelessWidget {
columnChildren.add(inputChild); columnChildren.add(inputChild);
} else { } else {
final Color borderColor = errorText == null ? activeColor : themeData.errorColor; final Color borderColor = errorText == null ? activeColor : themeData.errorColor;
columnChildren.add(_buildContent(borderColor, topPadding, isDense, inputChild, subTextHeight)); columnChildren.add(_buildContent(borderColor, topPadding, isDense, inputChild));
} }
if (errorText != null || helperText != null) { if (errorText != null || helperText != null || counterText != null) {
assert(!isCollapsed); assert(!isCollapsed, "Collapsed fields can't have errorText, helperText, or counterText set.");
final double linePadding = _kBottomBorderHeight + (isDense ? _kDensePadding : _kNormalPadding); final EdgeInsets topPadding = new EdgeInsets.only(
columnChildren.add( top: _kBottomBorderHeight + (isDense ? _kDensePadding : _kNormalPadding)
new AnimatedContainer( );
padding: new EdgeInsets.only(top: linePadding),
Widget buildSubText() {
return new AnimatedContainer(
padding: topPadding,
duration: _kTransitionDuration, duration: _kTransitionDuration,
curve: _kTransitionCurve, curve: _kTransitionCurve,
child: new Text( child: new Text(
...@@ -562,8 +594,39 @@ class InputDecorator extends StatelessWidget { ...@@ -562,8 +594,39 @@ class InputDecorator extends StatelessWidget {
textAlign: textAlign, textAlign: textAlign,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), );
); }
Widget buildCounter() {
return new AnimatedContainer(
padding: topPadding,
duration: _kTransitionDuration,
curve: _kTransitionCurve,
child: new Text(
counterText,
style: counterStyle,
textAlign: textAlign == TextAlign.end ? TextAlign.start : TextAlign.end,
overflow: TextOverflow.ellipsis,
),
);
}
final bool needSubTextField = errorText != null || helperText != null;
final bool needCounterField = counterText != null;
if (needCounterField && needSubTextField) {
columnChildren.add(
new Row(
children: <Widget>[
new Expanded(child: buildSubText()),
buildCounter(),
],
),
);
} else if (needSubTextField) {
columnChildren.add(buildSubText());
} else if (needCounterField) {
columnChildren.add(buildCounter());
}
} }
stackChildren.add( stackChildren.add(
......
...@@ -68,11 +68,30 @@ class TextField extends StatefulWidget { ...@@ -68,11 +68,30 @@ class TextField extends StatefulWidget {
/// The [maxLines] property can be set to null to remove the restriction on /// The [maxLines] property can be set to null to remove the restriction on
/// the number of lines. By default, it is one, meaning this is a single-line /// the number of lines. By default, it is one, meaning this is a single-line
/// text field. [maxLines] must not be zero. If [maxLines] is not one, then /// text field. [maxLines] must not be zero. If [maxLines] is not one, then
/// [keyboardType] is ignored, and the [TextInputType.multiline] keyboard type /// [keyboardType] is ignored, and the [TextInputType.multiline] keyboard
/// is used. /// type is used.
///
/// The [maxLength] property is set to null by default, which means the
/// number of characters allowed in the text field is not restricted. If
/// [maxLength] is set, a character counter will be displayed below the
/// field, showing how many characters have been entered and how many are
/// allowed. After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforced] is set to false. The TextField
/// enforces the length with a [LengthLimitingTextInputFormatter], which is
/// evaluated after the supplied [inputFormatters], if any. The [maxLength]
/// value must be either null or greater than zero.
///
/// If [maxLengthEnforced] is set to false, then more than [maxLength]
/// characters may be entered, and the error counter and divider will
/// switch to the [decoration.errorStyle] when the limit is exceeded.
/// ///
/// The [keyboardType], [textAlign], [autofocus], [obscureText], and /// The [keyboardType], [textAlign], [autofocus], [obscureText], and
/// [autocorrect] arguments must not be null. /// [autocorrect] arguments must not be null.
///
/// See also:
///
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
const TextField({ const TextField({
Key key, Key key,
this.controller, this.controller,
...@@ -85,6 +104,8 @@ class TextField extends StatefulWidget { ...@@ -85,6 +104,8 @@ class TextField extends StatefulWidget {
this.obscureText: false, this.obscureText: false,
this.autocorrect: true, this.autocorrect: true,
this.maxLines: 1, this.maxLines: 1,
this.maxLength,
this.maxLengthEnforced: true,
this.onChanged, this.onChanged,
this.onSubmitted, this.onSubmitted,
this.inputFormatters, this.inputFormatters,
...@@ -93,7 +114,9 @@ class TextField extends StatefulWidget { ...@@ -93,7 +114,9 @@ class TextField extends StatefulWidget {
assert(autofocus != null), assert(autofocus != null),
assert(obscureText != null), assert(obscureText != null),
assert(autocorrect != null), assert(autocorrect != null),
assert(maxLengthEnforced != null),
assert(maxLines == null || maxLines > 0), assert(maxLines == null || maxLines > 0),
assert(maxLength == null || maxLength > 0),
keyboardType = maxLines == 1 ? keyboardType : TextInputType.multiline, keyboardType = maxLines == 1 ? keyboardType : TextInputType.multiline,
super(key: key); super(key: key);
...@@ -168,6 +191,59 @@ class TextField extends StatefulWidget { ...@@ -168,6 +191,59 @@ class TextField extends StatefulWidget {
/// null, the value must be greater than zero. /// null, the value must be greater than zero.
final int maxLines; final int maxLines;
/// The maximum number of characters (Unicode scalar values) to allow in the
/// text field.
///
/// If set, a character counter will be displayed below the
/// field, showing how many characters have been entered and how many are
/// allowed. After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforced] is set to false. The TextField
/// enforces the length with a [LengthLimitingTextInputFormatter], which is
/// evaluated after the supplied [inputFormatters], if any.
///
/// This value must be either null or greater than zero. If set to null
/// (the default), there is no limit to the number of characters allowed.
///
/// Whitespace characters (e.g. newline, space, tab) are included in the
/// character count.
///
/// If [maxLengthEnforced] is set to false, then more than [maxLength]
/// characters may be entered, but the error counter and divider will
/// switch to the [decoration.errorStyle] when the limit is exceeded.
///
/// The TextField does not currently count Unicode grapheme clusters (i.e.
/// characters visible to the user), it counts Unicode scalar values, which
/// leaves out a number of useful possible characters (like many emoji and
/// composed characters), so this will be inaccurate in the presence of those
/// characters. If you expect to encounter these kinds of characters, be
/// generous in the maxLength used.
///
/// For instance, the character "ö" can be represented as '\u{006F}\u{0308}',
/// which is the letter "o" followed by a composed diaeresis "¨", or it can
/// be represented as '\u{00F6}', which is the Unicode scalar value "LATIN
/// SMALL LETTER O WITH DIAERESIS". In the first case, the text field will
/// count two characters, and the second case will be counted as one
/// character, even though the user can see no difference in the input.
///
/// Similarly, some emoji are represented by multiple scalar values. The
/// Unicode "THUMBS UP SIGN + MEDIUM SKIN TONE MODIFIER", "👍🏽", should be
/// counted as a single character, but because it is a combination of two
/// Unicode scalar values, '\u{1F44D}\u{1F3FD}', it is counted as two
/// characters.
///
/// See also:
/// * [LengthLimitingTextInputFormatter] for more information on how it
/// counts characters, and how it may differ from the intuitive meaning.
final int maxLength;
/// If true, prevents the field from allowing more than [maxLength]
/// characters.
///
/// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to
/// enforce the limit, or merely provide a character counter and warning when
/// [maxLength] is exceeded.
final bool maxLengthEnforced;
/// Called when the text being edited changes. /// Called when the text being edited changes.
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
...@@ -195,6 +271,8 @@ class TextField extends StatefulWidget { ...@@ -195,6 +271,8 @@ class TextField extends StatefulWidget {
description.add(new DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false)); description.add(new DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false));
description.add(new DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: false)); description.add(new DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: false));
description.add(new IntProperty('maxLines', maxLines, defaultValue: 1)); description.add(new IntProperty('maxLines', maxLines, defaultValue: 1));
description.add(new IntProperty('maxLength', maxLength, defaultValue: null));
description.add(new FlagProperty('maxLengthEnforced', value: maxLengthEnforced, ifTrue: 'max length enforced'));
} }
} }
...@@ -207,6 +285,28 @@ class _TextFieldState extends State<TextField> { ...@@ -207,6 +285,28 @@ class _TextFieldState extends State<TextField> {
FocusNode _focusNode; FocusNode _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= new FocusNode()); FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= new FocusNode());
bool get needsCounter => widget.maxLength != null
&& widget.decoration != null
&& widget.decoration.counterText == null;
InputDecoration _getEffectiveDecoration() {
if (!needsCounter)
return widget.decoration;
final InputDecoration effectiveDecoration = widget?.decoration ?? const InputDecoration();
final String counterText = '${_effectiveController.value.text.runes.length} / ${widget.maxLength}';
if (_effectiveController.value.text.runes.length > widget.maxLength) {
final ThemeData themeData = Theme.of(context);
return effectiveDecoration.copyWith(
errorText: effectiveDecoration.errorText ?? '',
counterStyle: effectiveDecoration.errorStyle
?? themeData.textTheme.caption.copyWith(color: themeData.errorColor),
counterText: counterText,
);
}
return effectiveDecoration.copyWith(counterText: counterText);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -244,6 +344,9 @@ class _TextFieldState extends State<TextField> { ...@@ -244,6 +344,9 @@ class _TextFieldState extends State<TextField> {
final TextStyle style = widget.style ?? themeData.textTheme.subhead; final TextStyle style = widget.style ?? themeData.textTheme.subhead;
final TextEditingController controller = _effectiveController; final TextEditingController controller = _effectiveController;
final FocusNode focusNode = _effectiveFocusNode; final FocusNode focusNode = _effectiveFocusNode;
final List<TextInputFormatter> formatters = widget.inputFormatters ?? <TextInputFormatter>[];
if (widget.maxLength != null && widget.maxLengthEnforced)
formatters.add(new LengthLimitingTextInputFormatter(widget.maxLength));
Widget child = new RepaintBoundary( Widget child = new RepaintBoundary(
child: new EditableText( child: new EditableText(
...@@ -265,7 +368,7 @@ class _TextFieldState extends State<TextField> { ...@@ -265,7 +368,7 @@ class _TextFieldState extends State<TextField> {
onChanged: widget.onChanged, onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress), onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress),
inputFormatters: widget.inputFormatters, inputFormatters: formatters,
), ),
); );
...@@ -274,7 +377,7 @@ class _TextFieldState extends State<TextField> { ...@@ -274,7 +377,7 @@ class _TextFieldState extends State<TextField> {
animation: new Listenable.merge(<Listenable>[ focusNode, controller ]), animation: new Listenable.merge(<Listenable>[ focusNode, controller ]),
builder: (BuildContext context, Widget child) { builder: (BuildContext context, Widget child) {
return new InputDecorator( return new InputDecorator(
decoration: widget.decoration, decoration: _getEffectiveDecoration(),
baseStyle: widget.style, baseStyle: widget.style,
textAlign: widget.textAlign, textAlign: widget.textAlign,
isFocused: focusNode.hasFocus, isFocused: focusNode.hasFocus,
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:math' as math;
import 'text_editing.dart'; import 'text_editing.dart';
import 'text_input.dart'; import 'text_input.dart';
...@@ -120,6 +122,84 @@ class BlacklistingTextInputFormatter extends TextInputFormatter { ...@@ -120,6 +122,84 @@ class BlacklistingTextInputFormatter extends TextInputFormatter {
= new BlacklistingTextInputFormatter(new RegExp(r'\n')); = new BlacklistingTextInputFormatter(new RegExp(r'\n'));
} }
/// A [TextInputFormatter] that prevents the insertion of more characters
/// (currently defined as Unicode scalar values) than allowed.
///
/// Since this formatter only prevents new characters from being added to the
/// text, it preserves the existing [TextEditingValue.selection].
///
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
class LengthLimitingTextInputFormatter extends TextInputFormatter {
/// Creates a formatter that prevents the insertion of more characters than a
/// limit.
///
/// The [maxLength] must be null or greater than zero. If it is null, then no
/// limit is enforced.
LengthLimitingTextInputFormatter(this.maxLength)
: assert(maxLength == null || maxLength > 0);
/// The limit on the number of characters (i.e. Unicode scalar values) this formatter
/// will allow.
///
/// The value must be null or greater than zero. If it is null, then no limit
/// is enforced.
///
/// This formatter does not currently count Unicode grapheme clusters (i.e.
/// characters visible to the user), it counts Unicode scalar values, which leaves
/// out a number of useful possible characters (like many emoji and composed
/// characters), so this will be inaccurate in the presence of those
/// characters. If you expect to encounter these kinds of characters, be
/// generous in the maxLength used.
///
/// For instance, the character "ö" can be represented as '\u{006F}\u{0308}',
/// which is the letter "o" followed by a composed diaeresis "¨", or it can
/// be represented as '\u{00F6}', which is the Unicode scalar value "LATIN
/// SMALL LETTER O WITH DIAERESIS". In the first case, the text field will
/// count two characters, and the second case will be counted as one
/// character, even though the user can see no difference in the input.
///
/// Similarly, some emoji are represented by multiple scalar values. The
/// Unicode "THUMBS UP SIGN + MEDIUM SKIN TONE MODIFIER", "👍🏽", should be
/// counted as a single character, but because it is a combination of two
/// Unicode scalar values, '\u{1F44D}\u{1F3FD}', it is counted as two
/// characters.
final int maxLength;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, // unused.
TextEditingValue newValue,
) {
if (maxLength != null && newValue.text.runes.length > maxLength) {
final TextSelection newSelection = newValue.selection.copyWith(
baseOffset: math.min(newValue.selection.start, maxLength),
extentOffset: math.min(newValue.selection.end, maxLength),
);
// This does not count grapheme clusters (i.e. characters visible to the user),
// it counts Unicode runes, which leaves out a number of useful possible
// characters (like many emoji), so this will be inaccurate in the
// presence of those characters. The Dart lang bug
// https://github.com/dart-lang/sdk/issues/28404 has been filed to
// address this in Dart.
// TODO(gspencer): convert this to count actual characters when Dart
// supports that.
final RuneIterator iterator = new RuneIterator(newValue.text);
if (iterator.moveNext())
for (int count = 0; count < maxLength; ++count)
if (!iterator.moveNext())
break;
final String truncated = newValue.text.substring(0, iterator.rawIndex);
return new TextEditingValue(
text: truncated,
selection: newSelection,
composing: TextRange.empty,
);
}
return newValue;
}
}
/// A [TextInputFormatter] that allows only the insertion of whitelisted /// A [TextInputFormatter] that allows only the insertion of whitelisted
/// characters patterns. /// characters patterns.
/// ///
......
...@@ -87,13 +87,14 @@ void main() { ...@@ -87,13 +87,14 @@ void main() {
expect(tester.element(find.byKey(key)).size, equals(const Size(800.0, 60.0))); expect(tester.element(find.byKey(key)).size, equals(const Size(800.0, 60.0)));
}); });
testWidgets('InputDecorator draws the underline correctly in the right place.', (WidgetTester tester) async { testWidgets('InputDecorator draws the divider correctly in the right place.', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildInputDecorator( buildInputDecorator(
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
), ),
), ),
); );
...@@ -103,13 +104,14 @@ void main() { ...@@ -103,13 +104,14 @@ void main() {
expect(getDividerWidth(tester), equals(800.0)); expect(getDividerWidth(tester), equals(800.0));
}); });
testWidgets('InputDecorator draws the underline correctly in the right place for dense layout.', (WidgetTester tester) async { testWidgets('InputDecorator draws the divider correctly in the right place for dense layout.', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
buildInputDecorator( buildInputDecorator(
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
isDense: true, isDense: true,
), ),
), ),
...@@ -127,6 +129,7 @@ void main() { ...@@ -127,6 +129,7 @@ void main() {
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
hideDivider: true, hideDivider: true,
), ),
), ),
...@@ -142,6 +145,7 @@ void main() { ...@@ -142,6 +145,7 @@ void main() {
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
isDense: true, isDense: true,
), ),
), ),
...@@ -157,9 +161,18 @@ void main() { ...@@ -157,9 +161,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(294.5)); expect(tester.getRect(find.text('Hint')).top, equals(294.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(716.0, 12.0), const Size(715.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(317.5)); expect(tester.getRect(find.text('Helper')).top, equals(317.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(716.0, 715.0));
expect(tester.getRect(find.text('Counter')).top, equals(317.5));
}); });
testWidgets('InputDecorator uses proper padding', (WidgetTester tester) async { testWidgets('InputDecorator uses proper padding', (WidgetTester tester) async {
...@@ -169,6 +182,7 @@ void main() { ...@@ -169,6 +182,7 @@ void main() {
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
), ),
), ),
); );
...@@ -183,9 +197,18 @@ void main() { ...@@ -183,9 +197,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(298.5)); expect(tester.getRect(find.text('Hint')).top, equals(298.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(325.5)); expect(tester.getRect(find.text('Helper')).top, equals(325.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(325.5));
}); });
testWidgets('InputDecorator uses proper padding when error is set', (WidgetTester tester) async { testWidgets('InputDecorator uses proper padding when error is set', (WidgetTester tester) async {
...@@ -196,6 +219,7 @@ void main() { ...@@ -196,6 +219,7 @@ void main() {
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
errorText: 'Error', errorText: 'Error',
counterText: 'Counter',
), ),
), ),
); );
...@@ -210,9 +234,18 @@ void main() { ...@@ -210,9 +234,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(298.5)); expect(tester.getRect(find.text('Hint')).top, equals(298.5));
expect(tester.getRect(find.text('Error')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Error')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Error')).left, equals(0.0)); expect(tester.getRect(find.text('Error')).left, equals(0.0));
expect(tester.getRect(find.text('Error')).top, equals(325.5)); expect(tester.getRect(find.text('Error')).top, equals(325.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(325.5));
}); });
testWidgets('InputDecorator animates properly', (WidgetTester tester) async { testWidgets('InputDecorator animates properly', (WidgetTester tester) async {
...@@ -228,6 +261,7 @@ void main() { ...@@ -228,6 +261,7 @@ void main() {
hintText: 'Hint', hintText: 'Hint',
labelText: 'Label', labelText: 'Label',
helperText: 'Helper', helperText: 'Helper',
counterText: 'Counter',
), ),
), ),
), ),
...@@ -245,9 +279,18 @@ void main() { ...@@ -245,9 +279,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(295.5)); expect(tester.getRect(find.text('Hint')).top, equals(295.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(328.5)); expect(tester.getRect(find.text('Helper')).top, equals(328.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(328.5));
expect(find.text('P'), findsNothing); expect(find.text('P'), findsNothing);
expect(find.text('S'), findsNothing); expect(find.text('S'), findsNothing);
...@@ -263,9 +306,18 @@ void main() { ...@@ -263,9 +306,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(295.5)); expect(tester.getRect(find.text('Hint')).top, equals(295.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(328.5)); expect(tester.getRect(find.text('Helper')).top, equals(328.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(328.5));
expect(find.text('P'), findsNothing); expect(find.text('P'), findsNothing);
expect(find.text('S'), findsNothing); expect(find.text('S'), findsNothing);
...@@ -280,9 +332,18 @@ void main() { ...@@ -280,9 +332,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(295.5)); expect(tester.getRect(find.text('Hint')).top, equals(295.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(328.5)); expect(tester.getRect(find.text('Helper')).top, equals(328.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(328.5));
expect(find.text('P'), findsNothing); expect(find.text('P'), findsNothing);
expect(find.text('S'), findsNothing); expect(find.text('S'), findsNothing);
...@@ -298,9 +359,18 @@ void main() { ...@@ -298,9 +359,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(295.5)); expect(tester.getRect(find.text('Hint')).top, equals(295.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(328.5)); expect(tester.getRect(find.text('Helper')).top, equals(328.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(328.5));
expect( expect(
tester.getRect(find.text('P')).size, tester.getRect(find.text('P')).size,
anyOf(<Size>[const Size(17.0, 16.0), const Size(16.0, 16.0)]), anyOf(<Size>[const Size(17.0, 16.0), const Size(16.0, 16.0)]),
...@@ -325,9 +395,18 @@ void main() { ...@@ -325,9 +395,18 @@ void main() {
expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0))); expect(tester.getRect(find.text('Hint')).size, equals(const Size(800.0, 16.0)));
expect(tester.getRect(find.text('Hint')).left, equals(0.0)); expect(tester.getRect(find.text('Hint')).left, equals(0.0));
expect(tester.getRect(find.text('Hint')).top, equals(295.5)); expect(tester.getRect(find.text('Hint')).top, equals(295.5));
expect(tester.getRect(find.text('Helper')).size, equals(const Size(800.0, 12.0))); expect(
tester.getRect(find.text('Helper')).size,
anyOf(<Size>[const Size(715.0, 12.0), const Size(716.0, 12.0)]),
);
expect(tester.getRect(find.text('Helper')).left, equals(0.0)); expect(tester.getRect(find.text('Helper')).left, equals(0.0));
expect(tester.getRect(find.text('Helper')).top, equals(328.5)); expect(tester.getRect(find.text('Helper')).top, equals(328.5));
expect(
tester.getRect(find.text('Counter')).size,
anyOf(<Size>[const Size(84.0, 12.0), const Size(85.0, 12.0)]),
);
expect(tester.getRect(find.text('Counter')).left, anyOf(715.0, 716.0));
expect(tester.getRect(find.text('Counter')).top, equals(328.5));
expect( expect(
tester.getRect(find.text('P')).size, tester.getRect(find.text('P')).size,
anyOf(<Size>[const Size(17.0, 16.0), const Size(16.0, 16.0)]), anyOf(<Size>[const Size(17.0, 16.0), const Size(16.0, 16.0)]),
......
...@@ -1520,4 +1520,121 @@ void main() { ...@@ -1520,4 +1520,121 @@ void main() {
controller.selection = const TextSelection.collapsed(offset: 10); controller.selection = const TextSelection.collapsed(offset: 10);
}, throwsFlutterError); }, throwsFlutterError);
}); });
testWidgets('maxLength limits input.', (WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(boilerplate(
child: new TextField(
controller: textController,
maxLength: 10,
),
));
await tester.enterText(find.byType(TextField), '0123456789101112');
expect(textController.text, '0123456789');
});
testWidgets('maxLength limits input length even if decoration is null.', (WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(boilerplate(
child: new TextField(
controller: textController,
decoration: null,
maxLength: 10,
),
));
await tester.enterText(find.byType(TextField), '0123456789101112');
expect(textController.text, '0123456789');
});
testWidgets('maxLength still works with other formatters.', (WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(boilerplate(
child: new TextField(
controller: textController,
maxLength: 10,
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("maxLength isn't enforced when maxLengthEnforced is false.", (WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(boilerplate(
child: new TextField(
controller: textController,
maxLength: 10,
maxLengthEnforced: false,
),
));
await tester.enterText(find.byType(TextField), '0123456789101112');
expect(textController.text, '0123456789101112');
});
testWidgets('maxLength shows warning when maxLengthEnforced is false.', (WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
final TextStyle testStyle = const TextStyle(color: Colors.deepPurpleAccent);
await tester.pumpWidget(boilerplate(
child: new TextField(
decoration: new InputDecoration(errorStyle: testStyle),
controller: textController,
maxLength: 10,
maxLengthEnforced: false,
),
));
await tester.enterText(find.byType(TextField), '0123456789101112');
await tester.pump();
expect(textController.text, '0123456789101112');
expect(find.text('16 / 10'), findsOneWidget);
Text counterTextWidget = tester.widget(find.text('16 / 10'));
expect(counterTextWidget.style.color, equals(Colors.deepPurpleAccent));
await tester.enterText(find.byType(TextField), '0123456789');
await tester.pump();
expect(textController.text, '0123456789');
expect(find.text('10 / 10'), findsOneWidget);
counterTextWidget = tester.widget(find.text('10 / 10'));
expect(counterTextWidget.style.color, isNot(equals(Colors.deepPurpleAccent)));
});
testWidgets('setting maxLength shows counter', (WidgetTester tester) async {
await tester.pumpWidget(new MaterialApp(
home: const Material(
child: const DefaultTextStyle(
style: const TextStyle(fontFamily: 'Ahem', fontSize: 10.0),
child: const Center(
child: const TextField(
maxLength: 10,
),
),
),
),
));
expect(find.text('0 / 10'), findsOneWidget);
await tester.enterText(find.byType(TextField), '01234');
await tester.pump();
expect(find.text('5 / 10'), findsOneWidget);
});
} }
...@@ -33,7 +33,7 @@ void main() { ...@@ -33,7 +33,7 @@ void main() {
group('test provided formatters', () { group('test provided formatters', () {
setUp(() { setUp(() {
// a1b(2c3 // a1b(2c3
// d4)e5f6 // d4)e5f6
// where the parentheses are the selection range. // where the parentheses are the selection range.
testNewValue = const TextEditingValue( testNewValue = const TextEditingValue(
text: 'a1b2c3\nd4e5f6', text: 'a1b2c3\nd4e5f6',
...@@ -51,7 +51,7 @@ void main() { ...@@ -51,7 +51,7 @@ void main() {
// Expecting // Expecting
// 1(23 // 1(23
// 4)56 // 4)56
expect(actualValue, const TextEditingValue( expect(actualValue, const TextEditingValue(
text: '123\n456', text: '123\n456',
selection: const TextSelection( selection: const TextSelection(
...@@ -67,7 +67,7 @@ void main() { ...@@ -67,7 +67,7 @@ void main() {
.formatEditUpdate(testOldValue, testNewValue); .formatEditUpdate(testOldValue, testNewValue);
// Expecting // Expecting
// a1b(2c3d4)e5f6 // a1b(2c3d4)e5f6
expect(actualValue, const TextEditingValue( expect(actualValue, const TextEditingValue(
text: 'a1b2c3d4e5f6', text: 'a1b2c3d4e5f6',
selection: const TextSelection( selection: const TextSelection(
...@@ -99,7 +99,7 @@ void main() { ...@@ -99,7 +99,7 @@ void main() {
.formatEditUpdate(testOldValue, testNewValue); .formatEditUpdate(testOldValue, testNewValue);
// Expecting // Expecting
// 1(234)56 // 1(234)56
expect(actualValue, const TextEditingValue( expect(actualValue, const TextEditingValue(
text: '123456', text: '123456',
selection: const TextSelection( selection: const TextSelection(
...@@ -108,5 +108,141 @@ void main() { ...@@ -108,5 +108,141 @@ void main() {
), ),
)); ));
}); });
test('test length limiting formatter', () {
final TextEditingValue actualValue =
new LengthLimitingTextInputFormatter(6)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1b(2c3)
expect(actualValue, const TextEditingValue(
text: 'a1b2c3',
selection: const TextSelection(
baseOffset: 3,
extentOffset: 6,
),
));
});
test('test length limiting formatter with zero-length string', () {
testNewValue = const TextEditingValue(
text: '',
selection: const TextSelection(
baseOffset: 0,
extentOffset: 0,
),
);
final TextEditingValue actualValue =
new LengthLimitingTextInputFormatter(1)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting the empty string.
expect(actualValue, const TextEditingValue(
text: '',
selection: const TextSelection(
baseOffset: 0,
extentOffset: 0,
),
));
});
test('test length limiting formatter with non-BMP Unicode scalar values', () {
testNewValue = const TextEditingValue(
text: '\u{1f984}\u{1f984}\u{1f984}\u{1f984}', // Unicode U+1f984 (UNICORN FACE)
selection: const TextSelection(
baseOffset: 4,
extentOffset: 4,
),
);
final TextEditingValue actualValue =
new LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting two runes.
expect(actualValue, const TextEditingValue(
text: '\u{1f984}\u{1f984}',
selection: const TextSelection(
baseOffset: 2,
extentOffset: 2,
),
));
});
test('test length limiting formatter with complex Unicode characters', () {
// TODO(gspencer): Test additional strings. We can do this once the
// formatter supports Unicode grapheme clusters.
//
// A formatter with max length 1 should accept:
// - The '\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}' sequence (flag followed by
// a variation selector, a zero-width joiner, and a rainbow to make a rainbow
// flag).
// - The sequence '\u{0058}\u{0346}\u{0361}\u{035E}\u{032A}\u{031C}\u{0333}\u{0326}\u{031D}\u{0332}'
// (Latin X with many composed characters).
//
// A formatter should not count as a character:
// * The '\u{0000}\u{FEFF}' sequence. (NULL followed by zero-width no-break space).
//
// A formatter with max length 1 should truncate this to one character:
// * The '\u{1F3F3}\u{FE0F}\u{1F308}' sequence (flag with ignored variation
// selector followed by rainbow, should truncate to just flag).
// The U+1F984 U+0020 sequence: Unicorn face followed by a space should
// yield only the unicorn face.
testNewValue = const TextEditingValue(
text: '\u{1F984}\u{0020}',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
TextEditingValue actualValue = new LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{1F984}',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
// The U+0058 U+0059 sequence: Latin X followed by Latin Y, should yield
// Latin X.
testNewValue = const TextEditingValue(
text: '\u{0058}\u{0059}',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 1,
),
);
actualValue = new LengthLimitingTextInputFormatter(1).formatEditUpdate(testOldValue, testNewValue);
expect(actualValue, const TextEditingValue(
text: '\u{0058}',
selection: const TextSelection(
baseOffset: 1,
extentOffset: 1,
),
));
});
test('test length limiting formatter when selection is off the end', () {
final TextEditingValue actualValue =
new LengthLimitingTextInputFormatter(2)
.formatEditUpdate(testOldValue, testNewValue);
// Expecting
// a1()
expect(actualValue, const TextEditingValue(
text: 'a1',
selection: const TextSelection(
baseOffset: 2,
extentOffset: 2,
),
));
});
}); });
} }
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