diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index 11599b61e0..e4185c1772 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -54,6 +54,8 @@ class InputDecoration { this.prefixStyle, this.suffixText, this.suffixStyle, + this.counterText, + this.counterStyle, }) : isCollapsed = false; /// Creates a decoration that is the same size as the input field. @@ -78,7 +80,9 @@ class InputDecoration { prefixText = null, prefixStyle = null, suffixText = null, - suffixStyle = null; + suffixStyle = null, + counterText = null, + counterStyle = null; /// An icon to show before the input field. /// @@ -189,6 +193,17 @@ class InputDecoration { /// If null, defaults to the [hintStyle]. 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 /// with the new values. /// @@ -209,6 +224,8 @@ class InputDecoration { TextStyle prefixStyle, String suffixText, TextStyle suffixStyle, + String counterText, + TextStyle counterStyle, }) { return new InputDecoration( icon: icon ?? this.icon, @@ -226,6 +243,8 @@ class InputDecoration { prefixStyle: prefixStyle ?? this.prefixStyle, suffixText: suffixText ?? this.suffixText, suffixStyle: suffixStyle ?? this.suffixStyle, + counterText: counterText ?? this.counterText, + counterStyle: counterStyle ?? this.counterStyle, ); } @@ -251,7 +270,9 @@ class InputDecoration { && typedOther.prefixText == prefixText && typedOther.prefixStyle == prefixStyle && typedOther.suffixText == suffixText - && typedOther.suffixStyle == suffixStyle; + && typedOther.suffixStyle == suffixStyle + && typedOther.counterText == counterText + && typedOther.counterStyle == counterStyle; } @override @@ -273,6 +294,8 @@ class InputDecoration { prefixStyle, suffixText, suffixStyle, + counterText, + counterStyle, ); } @@ -303,6 +326,10 @@ class InputDecoration { description.add('suffixText: $suffixText'); if (suffixStyle != null) description.add('suffixStyle: $suffixStyle'); + if (counterText != null) + description.add('counterText: $counterText'); + if (counterStyle != null) + description.add('counterStyle: $counterStyle'); return 'InputDecoration(${description.join(', ')})'; } } @@ -398,7 +425,7 @@ class InputDecorator extends StatelessWidget { 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) { return new Container( padding: new EdgeInsets.only(top: topPadding, bottom: _kNormalPadding), @@ -434,6 +461,7 @@ class InputDecorator extends StatelessWidget { final String labelText = decoration.labelText; final String helperText = decoration.helperText; + final String counterText = decoration.counterText; final String hintText = decoration.hintText; final String errorText = decoration.errorText; @@ -446,12 +474,13 @@ class InputDecorator extends StatelessWidget { final TextStyle baseStyle = this.baseStyle ?? themeData.textTheme.subhead; 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 ? 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 subTextHeight = subtextStyle.fontSize * textScaleFactor; double topPadding = isCollapsed ? 0.0 : (isDense ? _kDenseTopPadding : _kNormalTopPadding); @@ -545,15 +574,18 @@ class InputDecorator extends StatelessWidget { columnChildren.add(inputChild); } else { 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) { - assert(!isCollapsed); - final double linePadding = _kBottomBorderHeight + (isDense ? _kDensePadding : _kNormalPadding); - columnChildren.add( - new AnimatedContainer( - padding: new EdgeInsets.only(top: linePadding), + if (errorText != null || helperText != null || counterText != null) { + assert(!isCollapsed, "Collapsed fields can't have errorText, helperText, or counterText set."); + final EdgeInsets topPadding = new EdgeInsets.only( + top: _kBottomBorderHeight + (isDense ? _kDensePadding : _kNormalPadding) + ); + + Widget buildSubText() { + return new AnimatedContainer( + padding: topPadding, duration: _kTransitionDuration, curve: _kTransitionCurve, child: new Text( @@ -562,8 +594,39 @@ class InputDecorator extends StatelessWidget { textAlign: textAlign, 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: [ + new Expanded(child: buildSubText()), + buildCounter(), + ], + ), + ); + } else if (needSubTextField) { + columnChildren.add(buildSubText()); + } else if (needCounterField) { + columnChildren.add(buildCounter()); + } } stackChildren.add( diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 2638912716..6cc38ead01 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -68,11 +68,30 @@ class TextField extends StatefulWidget { /// 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 /// text field. [maxLines] must not be zero. If [maxLines] is not one, then - /// [keyboardType] is ignored, and the [TextInputType.multiline] keyboard type - /// is used. + /// [keyboardType] is ignored, and the [TextInputType.multiline] keyboard + /// 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 /// [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({ Key key, this.controller, @@ -85,6 +104,8 @@ class TextField extends StatefulWidget { this.obscureText: false, this.autocorrect: true, this.maxLines: 1, + this.maxLength, + this.maxLengthEnforced: true, this.onChanged, this.onSubmitted, this.inputFormatters, @@ -93,7 +114,9 @@ class TextField extends StatefulWidget { assert(autofocus != null), assert(obscureText != null), assert(autocorrect != null), + assert(maxLengthEnforced != null), assert(maxLines == null || maxLines > 0), + assert(maxLength == null || maxLength > 0), keyboardType = maxLines == 1 ? keyboardType : TextInputType.multiline, super(key: key); @@ -168,6 +191,59 @@ class TextField extends StatefulWidget { /// null, the value must be greater than zero. 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. final ValueChanged onChanged; @@ -195,6 +271,8 @@ class TextField extends StatefulWidget { description.add(new DiagnosticsProperty('obscureText', obscureText, defaultValue: false)); description.add(new DiagnosticsProperty('autocorrect', autocorrect, defaultValue: false)); 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 { FocusNode _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 void initState() { super.initState(); @@ -244,6 +344,9 @@ class _TextFieldState extends State { final TextStyle style = widget.style ?? themeData.textTheme.subhead; final TextEditingController controller = _effectiveController; final FocusNode focusNode = _effectiveFocusNode; + final List formatters = widget.inputFormatters ?? []; + if (widget.maxLength != null && widget.maxLengthEnforced) + formatters.add(new LengthLimitingTextInputFormatter(widget.maxLength)); Widget child = new RepaintBoundary( child: new EditableText( @@ -265,7 +368,7 @@ class _TextFieldState extends State { onChanged: widget.onChanged, onSubmitted: widget.onSubmitted, onSelectionChanged: (TextSelection _, bool longPress) => _onSelectionChanged(context, longPress), - inputFormatters: widget.inputFormatters, + inputFormatters: formatters, ), ); @@ -274,7 +377,7 @@ class _TextFieldState extends State { animation: new Listenable.merge([ focusNode, controller ]), builder: (BuildContext context, Widget child) { return new InputDecorator( - decoration: widget.decoration, + decoration: _getEffectiveDecoration(), baseStyle: widget.style, textAlign: widget.textAlign, isFocused: focusNode.hasFocus, diff --git a/packages/flutter/lib/src/services/text_formatter.dart b/packages/flutter/lib/src/services/text_formatter.dart index 42e377049e..44979c7754 100644 --- a/packages/flutter/lib/src/services/text_formatter.dart +++ b/packages/flutter/lib/src/services/text_formatter.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; + import 'text_editing.dart'; import 'text_input.dart'; @@ -120,6 +122,84 @@ class BlacklistingTextInputFormatter extends TextInputFormatter { = 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 /// characters patterns. /// diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index 847ae13486..6f873efe8d 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -87,13 +87,14 @@ void main() { 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( buildInputDecorator( decoration: const InputDecoration( hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', ), ), ); @@ -103,13 +104,14 @@ void main() { 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( buildInputDecorator( decoration: const InputDecoration( hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', isDense: true, ), ), @@ -127,6 +129,7 @@ void main() { hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', hideDivider: true, ), ), @@ -142,6 +145,7 @@ void main() { hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', isDense: true, ), ), @@ -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')).left, equals(0.0)); 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([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')).top, equals(317.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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 { @@ -169,6 +182,7 @@ void main() { hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', ), ), ); @@ -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')).left, equals(0.0)); 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([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')).top, equals(325.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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 { @@ -196,6 +219,7 @@ void main() { labelText: 'Label', helperText: 'Helper', errorText: 'Error', + counterText: 'Counter', ), ), ); @@ -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')).left, equals(0.0)); 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([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')).top, equals(325.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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 { @@ -228,6 +261,7 @@ void main() { hintText: 'Hint', labelText: 'Label', helperText: 'Helper', + counterText: 'Counter', ), ), ), @@ -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')).left, equals(0.0)); 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([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')).top, equals(328.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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('S'), findsNothing); @@ -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')).left, equals(0.0)); 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([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')).top, equals(328.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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('S'), findsNothing); @@ -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')).left, equals(0.0)); 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([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')).top, equals(328.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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('S'), findsNothing); @@ -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')).left, equals(0.0)); 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([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')).top, equals(328.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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( tester.getRect(find.text('P')).size, anyOf([const Size(17.0, 16.0), const Size(16.0, 16.0)]), @@ -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')).left, equals(0.0)); 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([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')).top, equals(328.5)); + expect( + tester.getRect(find.text('Counter')).size, + anyOf([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( tester.getRect(find.text('P')).size, anyOf([const Size(17.0, 16.0), const Size(16.0, 16.0)]), diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 36decc9d8d..5cc9ce648b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -1520,4 +1520,121 @@ void main() { controller.selection = const TextSelection.collapsed(offset: 10); }, 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: [ + 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); + }); } diff --git a/packages/flutter/test/widgets/text_formatter_test.dart b/packages/flutter/test/widgets/text_formatter_test.dart index 80382cbd79..a7f06ff8d3 100644 --- a/packages/flutter/test/widgets/text_formatter_test.dart +++ b/packages/flutter/test/widgets/text_formatter_test.dart @@ -33,7 +33,7 @@ void main() { group('test provided formatters', () { setUp(() { // a1b(2c3 - // d4)e5f6 + // d4)e5f6 // where the parentheses are the selection range. testNewValue = const TextEditingValue( text: 'a1b2c3\nd4e5f6', @@ -51,7 +51,7 @@ void main() { // Expecting // 1(23 - // 4)56 + // 4)56 expect(actualValue, const TextEditingValue( text: '123\n456', selection: const TextSelection( @@ -67,7 +67,7 @@ void main() { .formatEditUpdate(testOldValue, testNewValue); // Expecting - // a1b(2c3d4)e5f6 + // a1b(2c3d4)e5f6 expect(actualValue, const TextEditingValue( text: 'a1b2c3d4e5f6', selection: const TextSelection( @@ -99,7 +99,7 @@ void main() { .formatEditUpdate(testOldValue, testNewValue); // Expecting - // 1(234)56 + // 1(234)56 expect(actualValue, const TextEditingValue( text: '123456', selection: const TextSelection( @@ -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, + ), + )); + }); + }); }