Unverified Commit 135a8c22 authored by Alex Li's avatar Alex Li Committed by GitHub

Introduce `MaxLengthEnforcement` (#68086)

parent 635dfc3e
...@@ -260,6 +260,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -260,6 +260,7 @@ class CupertinoTextField extends StatefulWidget {
this.expands = false, this.expands = false,
this.maxLength, this.maxLength,
this.maxLengthEnforced = true, this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged, this.onChanged,
this.onEditingComplete, this.onEditingComplete,
this.onSubmitted, this.onSubmitted,
...@@ -292,6 +293,10 @@ class CupertinoTextField extends StatefulWidget { ...@@ -292,6 +293,10 @@ class CupertinoTextField extends StatefulWidget {
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(enableSuggestions != null), assert(enableSuggestions != null),
assert(maxLengthEnforced != null), assert(maxLengthEnforced != null),
assert(
maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(selectionHeightStyle != null), assert(selectionHeightStyle != null),
...@@ -402,6 +407,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -402,6 +407,7 @@ class CupertinoTextField extends StatefulWidget {
this.expands = false, this.expands = false,
this.maxLength, this.maxLength,
this.maxLengthEnforced = true, this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged, this.onChanged,
this.onEditingComplete, this.onEditingComplete,
this.onSubmitted, this.onSubmitted,
...@@ -434,6 +440,10 @@ class CupertinoTextField extends StatefulWidget { ...@@ -434,6 +440,10 @@ class CupertinoTextField extends StatefulWidget {
smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(enableSuggestions != null), assert(enableSuggestions != null),
assert(maxLengthEnforced != null), assert(maxLengthEnforced != null),
assert(
maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(selectionHeightStyle != null), assert(selectionHeightStyle != null),
...@@ -619,12 +629,13 @@ class CupertinoTextField extends StatefulWidget { ...@@ -619,12 +629,13 @@ class CupertinoTextField extends StatefulWidget {
/// The maximum number of characters (Unicode scalar values) to allow in the /// The maximum number of characters (Unicode scalar values) to allow in the
/// text field. /// text field.
/// ///
/// If set, a character counter will be displayed below the /// After [maxLength] characters have been input, additional input
/// field, showing how many characters have been entered and how many are /// is ignored, unless [maxLengthEnforcement] is set to
/// allowed. After [maxLength] characters have been input, additional input /// [MaxLengthEnforcement.none].
/// is ignored, unless [maxLengthEnforced] is set to false. The TextField ///
/// enforces the length with a [LengthLimitingTextInputFormatter], which is /// The TextField enforces the length with a
/// evaluated after the supplied [inputFormatters], if any. /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied
/// [inputFormatters], if any.
/// ///
/// This value must be either null or greater than zero. If set to null /// 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. /// (the default), there is no limit to the number of characters allowed.
...@@ -635,14 +646,23 @@ class CupertinoTextField extends StatefulWidget { ...@@ -635,14 +646,23 @@ class CupertinoTextField extends StatefulWidget {
/// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength}
final int? maxLength; final int? maxLength;
/// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to
/// enforce the limit.
///
/// If true, prevents the field from allowing more than [maxLength] /// If true, prevents the field from allowing more than [maxLength]
/// characters. /// 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; final bool maxLengthEnforced;
/// Determines how the [maxLength] limit should be enforced.
///
/// If [MaxLengthEnforcement.none] is set, additional input beyond [maxLength]
/// will not be enforced by the limit.
///
/// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement}
///
/// {@macro flutter.services.textFormatter.maxLengthEnforcement}
final MaxLengthEnforcement? maxLengthEnforcement;
/// {@macro flutter.widgets.editableText.onChanged} /// {@macro flutter.widgets.editableText.onChanged}
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
...@@ -761,6 +781,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -761,6 +781,7 @@ class CupertinoTextField extends StatefulWidget {
properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, ifTrue: 'max length enforced')); properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, ifTrue: 'max length enforced'));
properties.add(EnumProperty<MaxLengthEnforcement>('maxLengthEnforcement', maxLengthEnforcement, defaultValue: null));
properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); properties.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null));
properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null)); properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
...@@ -783,6 +804,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -783,6 +804,9 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
FocusNode? _focusNode; FocusNode? _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
MaxLengthEnforcement get _effectiveMaxLengthEnforcement => widget.maxLengthEnforcement
?? LengthLimitingTextInputFormatter.inferredDefaultMaxLengthEnforcement;
bool _showSelectionHandles = false; bool _showSelectionHandles = false;
late _CupertinoTextFieldSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder; late _CupertinoTextFieldSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
...@@ -1030,7 +1054,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1030,7 +1054,11 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
final Offset cursorOffset = Offset(_iOSHorizontalCursorOffsetPixels / MediaQuery.of(context).devicePixelRatio, 0); final Offset cursorOffset = Offset(_iOSHorizontalCursorOffsetPixels / MediaQuery.of(context).devicePixelRatio, 0);
final List<TextInputFormatter> formatters = <TextInputFormatter>[ final List<TextInputFormatter> formatters = <TextInputFormatter>[
...?widget.inputFormatters, ...?widget.inputFormatters,
if (widget.maxLength != null && widget.maxLengthEnforced) LengthLimitingTextInputFormatter(widget.maxLength) if (widget.maxLength != null && widget.maxLengthEnforced)
LengthLimitingTextInputFormatter(
widget.maxLength,
maxLengthEnforcement: _effectiveMaxLengthEnforcement,
),
]; ];
final CupertinoThemeData themeData = CupertinoTheme.of(context); final CupertinoThemeData themeData = CupertinoTheme.of(context);
......
...@@ -302,10 +302,11 @@ class TextField extends StatefulWidget { ...@@ -302,10 +302,11 @@ class TextField extends StatefulWidget {
/// [TextField.noMaxLength] then only the current length is displayed. /// [TextField.noMaxLength] then only the current length is displayed.
/// ///
/// After [maxLength] characters have been input, additional input /// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforced] is set to false. The text field /// is ignored, unless [maxLengthEnforcement] is set to
/// enforces the length with a [LengthLimitingTextInputFormatter], which is /// [MaxLengthEnforcement.none].
/// evaluated after the supplied [inputFormatters], if any. The [maxLength] /// The text field enforces the length with a [LengthLimitingTextInputFormatter],
/// value must be either null or greater than zero. /// 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] /// If [maxLengthEnforced] is set to false, then more than [maxLength]
/// characters may be entered, and the error counter and divider will /// characters may be entered, and the error counter and divider will
...@@ -356,6 +357,7 @@ class TextField extends StatefulWidget { ...@@ -356,6 +357,7 @@ class TextField extends StatefulWidget {
this.expands = false, this.expands = false,
this.maxLength, this.maxLength,
this.maxLengthEnforced = true, this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged, this.onChanged,
this.onEditingComplete, this.onEditingComplete,
this.onSubmitted, this.onSubmitted,
...@@ -391,6 +393,10 @@ class TextField extends StatefulWidget { ...@@ -391,6 +393,10 @@ class TextField extends StatefulWidget {
assert(enableSuggestions != null), assert(enableSuggestions != null),
assert(enableInteractiveSelection != null), assert(enableInteractiveSelection != null),
assert(maxLengthEnforced != null), assert(maxLengthEnforced != null),
assert(
maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(dragStartBehavior != null), assert(dragStartBehavior != null),
assert(selectionHeightStyle != null), assert(selectionHeightStyle != null),
...@@ -568,9 +574,11 @@ class TextField extends StatefulWidget { ...@@ -568,9 +574,11 @@ class TextField extends StatefulWidget {
/// to [TextField.noMaxLength] then only the current character count is displayed. /// to [TextField.noMaxLength] then only the current character count is displayed.
/// ///
/// After [maxLength] characters have been input, additional input /// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforced] is set to false. The text field /// is ignored, unless [maxLengthEnforcement] is set to
/// enforces the length with a [LengthLimitingTextInputFormatter], which is /// [MaxLengthEnforcement.none].
/// evaluated after the supplied [inputFormatters], if any. ///
/// The text field enforces the length with a [LengthLimitingTextInputFormatter],
/// which is evaluated after the supplied [inputFormatters], if any.
/// ///
/// This value must be either null, [TextField.noMaxLength], or greater than 0. /// This value must be either null, [TextField.noMaxLength], or greater than 0.
/// If null (the default) then there is no limit to the number of characters /// If null (the default) then there is no limit to the number of characters
...@@ -580,7 +588,8 @@ class TextField extends StatefulWidget { ...@@ -580,7 +588,8 @@ class TextField extends StatefulWidget {
/// Whitespace characters (e.g. newline, space, tab) are included in the /// Whitespace characters (e.g. newline, space, tab) are included in the
/// character count. /// character count.
/// ///
/// If [maxLengthEnforced] is set to false, then more than [maxLength] /// If [maxLengthEnforced] is set to false or [maxLengthEnforcement] is
/// [MaxLengthEnforcement.none], then more than [maxLength]
/// characters may be entered, but the error counter and divider will switch /// characters may be entered, but the error counter and divider will switch
/// to the [decoration]'s [InputDecoration.errorStyle] when the limit is /// to the [decoration]'s [InputDecoration.errorStyle] when the limit is
/// exceeded. /// exceeded.
...@@ -588,14 +597,21 @@ class TextField extends StatefulWidget { ...@@ -588,14 +597,21 @@ class TextField extends StatefulWidget {
/// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength}
final int? maxLength; final int? maxLength;
/// If true, prevents the field from allowing more than [maxLength]
/// characters.
///
/// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to /// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to
/// enforce the limit, or merely provide a character counter and warning when /// enforce the limit, or merely provide a character counter and warning when
/// [maxLength] is exceeded. /// [maxLength] is exceeded.
///
/// If true, prevents the field from allowing more than [maxLength]
/// characters.
final bool maxLengthEnforced; final bool maxLengthEnforced;
/// Determines how the [maxLength] limit should be enforced.
///
/// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement}
///
/// {@macro flutter.services.textFormatter.maxLengthEnforcement}
final MaxLengthEnforcement? maxLengthEnforcement;
/// {@macro flutter.widgets.editableText.onChanged} /// {@macro flutter.widgets.editableText.onChanged}
/// ///
/// See also: /// See also:
...@@ -810,6 +826,7 @@ class TextField extends StatefulWidget { ...@@ -810,6 +826,7 @@ class TextField extends StatefulWidget {
properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, defaultValue: true, ifFalse: 'maxLength not enforced')); properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, defaultValue: true, ifFalse: 'maxLength not enforced'));
properties.add(EnumProperty<MaxLengthEnforcement>('maxLengthEnforcement', maxLengthEnforcement, defaultValue: null));
properties.add(EnumProperty<TextInputAction>('textInputAction', textInputAction, defaultValue: null)); properties.add(EnumProperty<TextInputAction>('textInputAction', textInputAction, defaultValue: null));
properties.add(EnumProperty<TextCapitalization>('textCapitalization', textCapitalization, defaultValue: TextCapitalization.none)); properties.add(EnumProperty<TextCapitalization>('textCapitalization', textCapitalization, defaultValue: TextCapitalization.none));
properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: TextAlign.start)); properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: TextAlign.start));
...@@ -835,6 +852,9 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -835,6 +852,9 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
FocusNode? _focusNode; FocusNode? _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
MaxLengthEnforcement get _effectiveMaxLengthEnforcement => widget.maxLengthEnforcement
?? LengthLimitingTextInputFormatter.inferredDefaultMaxLengthEnforcement;
bool _isHovering = false; bool _isHovering = false;
bool get needsCounter => widget.maxLength != null bool get needsCounter => widget.maxLength != null
...@@ -1095,7 +1115,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1095,7 +1115,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
final FocusNode focusNode = _effectiveFocusNode; final FocusNode focusNode = _effectiveFocusNode;
final List<TextInputFormatter> formatters = <TextInputFormatter>[ final List<TextInputFormatter> formatters = <TextInputFormatter>[
...?widget.inputFormatters, ...?widget.inputFormatters,
if (widget.maxLength != null && widget.maxLengthEnforced) LengthLimitingTextInputFormatter(widget.maxLength) if (widget.maxLength != null && widget.maxLengthEnforced)
LengthLimitingTextInputFormatter(
widget.maxLength,
maxLengthEnforcement: _effectiveMaxLengthEnforcement,
),
]; ];
TextSelectionControls? textSelectionControls = widget.selectionControls; TextSelectionControls? textSelectionControls = widget.selectionControls;
...@@ -1226,6 +1250,16 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1226,6 +1250,16 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
}, },
); );
final int? semanticsMaxValueLength;
if (widget.maxLengthEnforced &&
_effectiveMaxLengthEnforcement != MaxLengthEnforcement.none &&
widget.maxLength != null &&
widget.maxLength! > 0) {
semanticsMaxValueLength = widget.maxLength;
} else {
semanticsMaxValueLength = null;
}
return MouseRegion( return MouseRegion(
cursor: effectiveMouseCursor, cursor: effectiveMouseCursor,
onEnter: (PointerEnterEvent event) => _handleHover(true), onEnter: (PointerEnterEvent event) => _handleHover(true),
...@@ -1236,9 +1270,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1236,9 +1270,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
animation: controller, // changes the _currentLength animation: controller, // changes the _currentLength
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
return Semantics( return Semantics(
maxValueLength: widget.maxLengthEnforced && widget.maxLength != null && widget.maxLength! > 0 maxValueLength: semanticsMaxValueLength,
? widget.maxLength
: null,
currentValueLength: _currentLength, currentValueLength: _currentLength,
onTap: () { onTap: () {
if (!_effectiveController.selection.isValid) if (!_effectiveController.selection.isValid)
......
...@@ -163,6 +163,7 @@ class TextFormField extends FormField<String> { ...@@ -163,6 +163,7 @@ class TextFormField extends FormField<String> {
) )
bool autovalidate = false, bool autovalidate = false,
bool maxLengthEnforced = true, bool maxLengthEnforced = true,
MaxLengthEnforcement? maxLengthEnforcement,
int? maxLines = 1, int? maxLines = 1,
int? minLines, int? minLines,
bool expands = false, bool expands = false,
...@@ -202,6 +203,10 @@ class TextFormField extends FormField<String> { ...@@ -202,6 +203,10 @@ class TextFormField extends FormField<String> {
'autovalidate and autovalidateMode should not be used together.' 'autovalidate and autovalidateMode should not be used together.'
), ),
assert(maxLengthEnforced != null), assert(maxLengthEnforced != null),
assert(
maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
),
assert(scrollPadding != null), assert(scrollPadding != null),
assert(maxLines == null || maxLines > 0), assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0), assert(minLines == null || minLines > 0),
...@@ -259,6 +264,7 @@ class TextFormField extends FormField<String> { ...@@ -259,6 +264,7 @@ class TextFormField extends FormField<String> {
smartQuotesType: smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), smartQuotesType: smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
enableSuggestions: enableSuggestions, enableSuggestions: enableSuggestions,
maxLengthEnforced: maxLengthEnforced, maxLengthEnforced: maxLengthEnforced,
maxLengthEnforcement: maxLengthEnforcement,
maxLines: maxLines, maxLines: maxLines,
minLines: minLines, minLines: minLines,
expands: expands, expands: expands,
......
...@@ -11,6 +11,49 @@ import 'package:flutter/foundation.dart'; ...@@ -11,6 +11,49 @@ import 'package:flutter/foundation.dart';
import 'text_editing.dart'; import 'text_editing.dart';
import 'text_input.dart'; import 'text_input.dart';
/// {@template flutter.services.textFormatter.maxLengthEnforcement}
/// ### [MaxLengthEnforcement.enforced] versus
/// [MaxLengthEnforcement.truncateAfterCompositionEnds]
///
/// Both [MaxLengthEnforcement.enforced] and
/// [MaxLengthEnforcement.truncateAfterCompositionEnds] make sure the final
/// length of the text does not exceed the max length specified. The difference
/// is that [MaxLengthEnforcement.enforced] truncates all text while
/// [MaxLengthEnforcement.truncateAfterCompositionEnds] allows composing text to
/// exceed the limit. Allowing this "placeholder" composing text to exceed the
/// limit may provide a better user experience on some platforms for entering
/// ideographic characters (e.g. CJK characters) via composing on phonetic
/// keyboards.
///
/// Some input methods (Gboard on Android for example) initiate text composition
/// even for Latin characters, in which case the best experience may be to
/// truncate those composing characters with [MaxLengthEnforcement.enforced].
///
/// In fields that strictly support only a small subset of characters, such as
/// verification code fields, [MaxLengthEnforcement.enforced] may provide the
/// best experience.
/// {@endtemplate}
///
/// See also:
///
/// * [TextField.maxLengthEnforcement] which is used in conjunction with
/// [TextField.maxLength] to limit the length of user input. [TextField] also
/// provides a character counter to provide visual feedback.
enum MaxLengthEnforcement {
/// No enforcement applied to the editing value. It's possible to exceed the
/// max length.
none,
/// Keep the length of the text input from exceeding the max length even when
/// the text has an unfinished composing region.
enforced,
/// Users can still input text if the current value is composing even after
/// reaching the max length limit. After composing ends, the value will be
/// truncated.
truncateAfterCompositionEnds,
}
/// A [TextInputFormatter] can be optionally injected into an [EditableText] /// A [TextInputFormatter] can be optionally injected into an [EditableText]
/// to provide as-you-type validation and formatting of the text being edited. /// to provide as-you-type validation and formatting of the text being edited.
/// ///
...@@ -322,8 +365,10 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { ...@@ -322,8 +365,10 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
/// ///
/// The [maxLength] must be null, -1 or greater than zero. If it is null or -1 /// The [maxLength] must be null, -1 or greater than zero. If it is null or -1
/// then no limit is enforced. /// then no limit is enforced.
LengthLimitingTextInputFormatter(this.maxLength) LengthLimitingTextInputFormatter(
: assert(maxLength == null || maxLength == -1 || maxLength > 0); this.maxLength, {
this.maxLengthEnforcement,
}) : assert(maxLength == null || maxLength == -1 || maxLength > 0);
/// The limit on the number of user-perceived characters that this formatter /// The limit on the number of user-perceived characters that this formatter
/// will allow. /// will allow.
...@@ -363,6 +408,47 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { ...@@ -363,6 +408,47 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
/// composing is not allowed. /// composing is not allowed.
final int? maxLength; final int? maxLength;
/// Determines how the [maxLength] limit should be enforced.
///
/// Defaults to [MaxLengthEnforcement.enforced].
///
/// {@macro flutter.services.textFormatter.maxLengthEnforcement}
final MaxLengthEnforcement? maxLengthEnforcement;
/// Return an effective [MaxLengthEnforcement] according the target platform.
///
/// {@template flutter.services.textFormatter.effectiveMaxLengthEnforcement}
/// ### Platform specific behaviors
///
/// Different platforms follow different behaviors by default, according to
/// their native behavior.
/// * Android, Windows: [MaxLengthEnforcement.enforced]. The native behavior
/// of these platforms is enforced. The composing will be handled by the
/// IME while users are entering CJK characters.
/// * iOS: [MaxLengthEnforcement.truncateAfterCompositionEnds]. iOS has no
/// default behavior and it requires users implement the behavior
/// themselves. Allow the composition to exceed to avoid breaking CJK input.
/// * Web, macOS, linux, fuchsia:
/// [MaxLengthEnforcement.truncateAfterCompositionEnds]. These platforms
/// allow the composition to exceed by default.
/// {@endtemplate}
static MaxLengthEnforcement get inferredDefaultMaxLengthEnforcement {
if (kIsWeb) {
return MaxLengthEnforcement.truncateAfterCompositionEnds;
} else {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.windows:
return MaxLengthEnforcement.enforced;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.fuchsia:
return MaxLengthEnforcement.truncateAfterCompositionEnds;
}
}
}
/// Truncate the given TextEditingValue to maxLength user-perceived /// Truncate the given TextEditingValue to maxLength user-perceived
/// characters. /// characters.
/// ///
...@@ -376,13 +462,19 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { ...@@ -376,13 +462,19 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
iterator.expandNext(maxLength); iterator.expandNext(maxLength);
} }
final String truncated = iterator.current; final String truncated = iterator.current;
return TextEditingValue( return TextEditingValue(
text: truncated, text: truncated,
selection: value.selection.copyWith( selection: value.selection.copyWith(
baseOffset: math.min(value.selection.start, truncated.length), baseOffset: math.min(value.selection.start, truncated.length),
extentOffset: math.min(value.selection.end, truncated.length), extentOffset: math.min(value.selection.end, truncated.length),
), ),
composing: TextRange.empty, composing: !value.composing.isCollapsed && truncated.length > value.composing.start
? TextRange(
start: value.composing.start,
end: math.min(value.composing.end, truncated.length),
)
: TextRange.empty,
); );
} }
...@@ -393,20 +485,43 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter { ...@@ -393,20 +485,43 @@ class LengthLimitingTextInputFormatter extends TextInputFormatter {
) { ) {
final int? maxLength = this.maxLength; final int? maxLength = this.maxLength;
if (maxLength == null || maxLength == -1 || newValue.text.characters.length <= maxLength) if (maxLength == null ||
maxLength == -1 ||
newValue.text.characters.length <= maxLength) {
return newValue; return newValue;
}
assert(maxLength > 0); assert(maxLength > 0);
// If already at the maximum and tried to enter even more, keep the old switch (maxLengthEnforcement ?? inferredDefaultMaxLengthEnforcement) {
// value. case MaxLengthEnforcement.none:
if (oldValue.text.characters.length == maxLength && !oldValue.composing.isValid) { return newValue;
case MaxLengthEnforcement.enforced:
// If already at the maximum and tried to enter even more, and has no
// selection, keep the old value.
if (oldValue.text.characters.length == maxLength && !oldValue.selection.isValid) {
return oldValue;
}
// Enforced to return a truncated value.
return truncate(newValue, maxLength);
case MaxLengthEnforcement.truncateAfterCompositionEnds:
// If already at the maximum and tried to enter even more, and the old
// value is not composing, keep the old value.
if (oldValue.text.characters.length == maxLength &&
!oldValue.composing.isValid) {
return oldValue; return oldValue;
} }
// Temporarily exempt `newValue` from the maxLength limit if it has a // Temporarily exempt `newValue` from the maxLength limit if it has a
// composing text going, until the composing is finished. // composing text going and no enforcement to the composing value, until
return newValue.composing.isValid ? newValue : truncate(newValue, maxLength); // the composing is finished.
if (newValue.composing.isValid) {
return newValue;
}
return truncate(newValue, maxLength);
}
} }
} }
......
...@@ -4302,4 +4302,126 @@ void main() { ...@@ -4302,4 +4302,126 @@ void main() {
expect(formatters.isEmpty, isTrue); expect(formatters.isEmpty, isTrue);
}); });
group('MaxLengthEnforcement', () {
const int maxLength = 5;
Future<void> setupWidget(
WidgetTester tester,
MaxLengthEnforcement? enforcement,
) async {
final Widget widget = CupertinoApp(
home: Center(
child: CupertinoTextField(
maxLength: maxLength,
maxLengthEnforcement: enforcement,
),
),
);
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
}
testWidgets('using none enforcement.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.none;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
testWidgets('using enforced.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.enforced;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
});
testWidgets('using truncateAfterCompositionEnds.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.truncateAfterCompositionEnds;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
testWidgets('using default behavior for different platforms.', (WidgetTester tester) async {
await setupWidget(tester, null);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: '侬好啊'));
expect(state.currentTextEditingValue.text, '侬好啊');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友们', composing: TextRange(start: 3, end: 6)));
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia
) {
expect(state.currentTextEditingValue.text, '侬好啊旁友们');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
} else {
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
}
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友'));
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
});
} }
...@@ -2196,7 +2196,7 @@ void main() { ...@@ -2196,7 +2196,7 @@ void main() {
TextFormField( TextFormField(
key: textFieldKey, key: textFieldKey,
maxLength: 3, maxLength: 3,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
decoration: InputDecoration( decoration: InputDecoration(
counterText: '', counterText: '',
errorText: errorText, errorText: errorText,
...@@ -3671,7 +3671,7 @@ void main() { ...@@ -3671,7 +3671,7 @@ void main() {
child: TextField( child: TextField(
controller: textController, controller: textController,
maxLength: 10, maxLength: 10,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
), ),
)); ));
...@@ -3688,7 +3688,7 @@ void main() { ...@@ -3688,7 +3688,7 @@ void main() {
decoration: const InputDecoration(errorStyle: testStyle), decoration: const InputDecoration(errorStyle: testStyle),
controller: textController, controller: textController,
maxLength: 10, maxLength: 10,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
), ),
)); ));
...@@ -3718,7 +3718,7 @@ void main() { ...@@ -3718,7 +3718,7 @@ void main() {
decoration: const InputDecoration(errorStyle: testStyle), decoration: const InputDecoration(errorStyle: testStyle),
controller: textController, controller: textController,
maxLength: 10, maxLength: 10,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
), ),
)); ));
...@@ -3748,7 +3748,7 @@ void main() { ...@@ -3748,7 +3748,7 @@ void main() {
decoration: const InputDecoration(errorStyle: testStyle), decoration: const InputDecoration(errorStyle: testStyle),
controller: textController, controller: textController,
maxLength: 10, maxLength: 10,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
), ),
)); ));
...@@ -7504,7 +7504,7 @@ void main() { ...@@ -7504,7 +7504,7 @@ void main() {
autocorrect: false, autocorrect: false,
maxLines: 10, maxLines: 10,
maxLength: 100, maxLength: 100,
maxLengthEnforced: false, maxLengthEnforcement: MaxLengthEnforcement.none,
smartDashesType: SmartDashesType.disabled, smartDashesType: SmartDashesType.disabled,
smartQuotesType: SmartQuotesType.disabled, smartQuotesType: SmartQuotesType.disabled,
enabled: false, enabled: false,
...@@ -7532,7 +7532,7 @@ void main() { ...@@ -7532,7 +7532,7 @@ void main() {
'smartQuotesType: disabled', 'smartQuotesType: disabled',
'maxLines: 10', 'maxLines: 10',
'maxLength: 100', 'maxLength: 100',
'maxLength not enforced', 'maxLengthEnforcement: none',
'textInputAction: done', 'textInputAction: done',
'textAlign: end', 'textAlign: end',
'textDirection: ltr', 'textDirection: ltr',
...@@ -8745,4 +8745,125 @@ void main() { ...@@ -8745,4 +8745,125 @@ void main() {
// The label will always float above the content. // The label will always float above the content.
expect(tester.getTopLeft(find.text('Label')).dy, 12.0); expect(tester.getTopLeft(find.text('Label')).dy, 12.0);
}); });
group('MaxLengthEnforcement', () {
const int maxLength = 5;
Future<void> setupWidget(
WidgetTester tester,
MaxLengthEnforcement? enforcement,
) async {
final Widget widget = MaterialApp(
home: Material(
child: TextField(
maxLength: maxLength,
maxLengthEnforcement: enforcement,
),
),
);
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
}
testWidgets('using none enforcement.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.none;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
testWidgets('using enforced.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.enforced;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
});
testWidgets('using truncateAfterCompositionEnds.', (WidgetTester tester) async {
const MaxLengthEnforcement enforcement = MaxLengthEnforcement.truncateAfterCompositionEnds;
await setupWidget(tester, enforcement);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.text, 'abc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
testWidgets('using default behavior for different platforms.', (WidgetTester tester) async {
await setupWidget(tester, null);
final EditableTextState state = tester.state(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: '侬好啊'));
expect(state.currentTextEditingValue.text, '侬好啊');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友们', composing: TextRange(start: 3, end: 6)));
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia
) {
expect(state.currentTextEditingValue.text, '侬好啊旁友们');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
} else {
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
}
state.updateEditingValue(const TextEditingValue(text: '侬好啊旁友'));
expect(state.currentTextEditingValue.text, '侬好啊旁友');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
});
} }
...@@ -6500,9 +6500,13 @@ void main() { ...@@ -6500,9 +6500,13 @@ void main() {
expect(state.currentTextEditingValue.composing, TextRange.empty); expect(state.currentTextEditingValue.composing, TextRange.empty);
}); });
// Regression test for https://github.com/flutter/flutter/issues/65374. group('Length formatter', () {
testWidgets('Length formatter will not cause crash while the TextEditingValue is composing', (WidgetTester tester) async { const int maxLength = 5;
final TextInputFormatter formatter = LengthLimitingTextInputFormatter(5);
Future<void> setupWidget(
WidgetTester tester,
LengthLimitingTextInputFormatter formatter,
) async {
final Widget widget = MaterialApp( final Widget widget = MaterialApp(
home: EditableText( home: EditableText(
backgroundCursorColor: Colors.grey, backgroundCursorColor: Colors.grey,
...@@ -6516,59 +6520,264 @@ void main() { ...@@ -6516,59 +6520,264 @@ void main() {
); );
await tester.pumpWidget(widget); await tester.pumpWidget(widget);
await tester.pumpAndSettle();
}
// Regression test for https://github.com/flutter/flutter/issues/65374.
testWidgets('will not cause crash while the TextEditingValue is composing', (WidgetTester tester) async {
await setupWidget(
tester,
LengthLimitingTextInputFormatter(
maxLength,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
),
);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
state.updateEditingValue(const TextEditingValue(text: '12345')); state.updateEditingValue(const TextEditingValue(text: 'abcde'));
expect(state.currentTextEditingValue.composing, TextRange.empty); expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: '12345', composing: TextRange(start: 2, end: 4))); state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 2, end: 4)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 4)); expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 4));
// Formatter will not update format while the editing value is composing. // Formatter will not update format while the editing value is composing.
state.updateEditingValue(const TextEditingValue(text: '123456', composing: TextRange(start: 2, end: 5))); state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 2, end: 5)));
expect(state.currentTextEditingValue.text, '123456'); expect(state.currentTextEditingValue.text, 'abcdef');
expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 5)); expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 5));
// After composing ends, formatter will update. // After composing ends, formatter will update.
state.updateEditingValue(const TextEditingValue(text: '123456')); state.updateEditingValue(const TextEditingValue(text: 'abcdef'));
expect(state.currentTextEditingValue.text, '12345'); expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, TextRange.empty); expect(state.currentTextEditingValue.composing, TextRange.empty);
}); });
testWidgets('Length formatter handles composing text correctly, continued', (WidgetTester tester) async { testWidgets('handles composing text correctly, continued', (WidgetTester tester) async {
final TextInputFormatter formatter = LengthLimitingTextInputFormatter(5); await setupWidget(
final Widget widget = MaterialApp( tester,
home: EditableText( LengthLimitingTextInputFormatter(
backgroundCursorColor: Colors.grey, maxLength,
controller: controller, maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
focusNode: focusNode,
inputFormatters: <TextInputFormatter>[formatter],
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
), ),
); );
await tester.pumpWidget(widget);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText)); final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're at maxLength with no composing text. // Initially we're at maxLength with no composing text.
controller.text = '12345' ; controller.text = 'abcde' ;
assert(state.currentTextEditingValue == const TextEditingValue(text: '12345')); assert(state.currentTextEditingValue == const TextEditingValue(text: 'abcde'));
// Should be able to change the editing value if the new value is still shorter // Should be able to change the editing value if the new value is still shorter
// than maxLength. // than maxLength.
state.updateEditingValue(const TextEditingValue(text: '12345', composing: TextRange(start: 2, end: 4))); state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 2, end: 4)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 4)); expect(state.currentTextEditingValue.composing, const TextRange(start: 2, end: 4));
// Reset. // Reset.
controller.text = '12345' ; controller.text = 'abcde' ;
assert(state.currentTextEditingValue == const TextEditingValue(text: '12345')); assert(state.currentTextEditingValue == const TextEditingValue(text: 'abcde'));
// The text should not change when trying to insert when the text is already // The text should not change when trying to insert when the text is already
// at maxLength. // at maxLength.
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 5, end: 6))); state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 5, end: 6)));
expect(state.currentTextEditingValue.text, '12345'); expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
// Regression test for https://github.com/flutter/flutter/issues/68086.
testWidgets('enforced composing truncated', (WidgetTester tester) async {
await setupWidget(
tester,
LengthLimitingTextInputFormatter(
maxLength,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
),
);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: 'abcde'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// When it's not longer than `maxLength`, it can still start composing.
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
// `newValue` will be truncated if `composingMaxLengthEnforced`.
state.updateEditingValue(const TextEditingValue(text: 'abcdef', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, 'abcde');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
// Reset the value.
state.updateEditingValue(const TextEditingValue(text: 'abcde'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Change the value in order to take effects on web test.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing with a longer value, it should be the same state.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友们', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
// Regression test for https://github.com/flutter/flutter/issues/68086.
testWidgets('default truncate behaviors with different platforms', (WidgetTester tester) async {
await setupWidget(tester, LengthLimitingTextInputFormatter(maxLength));
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// When it's not longer than `maxLength`, it can still start composing.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友们', composing: TextRange(start: 3, end: 6)));
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia
) {
// `newValue` will will not be truncated on couple platforms.
expect(state.currentTextEditingValue.text, '你好啊朋友们');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 6));
} else {
// `newValue` on other platforms will be truncated.
expect(state.currentTextEditingValue.text, '你好啊朋友');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
}
// Reset the value.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing with a longer value, it should be the same state.
state.updateEditingValue(const TextEditingValue(text: '你好啊朋友们', composing: TextRange(start: 3, end: 6)));
expect(state.currentTextEditingValue.text, '你好啊朋友');
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
// Regression test for https://github.com/flutter/flutter/issues/68086.
testWidgets('composing range removed if it\'s overflowed the truncated value\'s length', (WidgetTester tester) async {
await setupWidget(
tester,
LengthLimitingTextInputFormatter(
maxLength,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
),
);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're not at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing.
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
// Reset the value.
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing with a range already overflowed the truncated length.
state.updateEditingValue(const TextEditingValue(text: 'abcdefgh', composing: TextRange(start: 5, end: 7)));
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
// Regression test for https://github.com/flutter/flutter/issues/68086.
testWidgets('composing range removed with different platforms', (WidgetTester tester) async {
await setupWidget(tester, LengthLimitingTextInputFormatter(maxLength));
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're not at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing.
state.updateEditingValue(const TextEditingValue(text: 'abcde', composing: TextRange(start: 3, end: 5)));
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
// Reset the value.
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start composing with a range already overflowed the truncated length.
state.updateEditingValue(const TextEditingValue(text: 'abcdefgh', composing: TextRange(start: 5, end: 7)));
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia
) {
expect(state.currentTextEditingValue.composing, const TextRange(start: 5, end: 7));
} else {
expect(state.currentTextEditingValue.composing, TextRange.empty); expect(state.currentTextEditingValue.composing, TextRange.empty);
}
});
testWidgets('composing range handled correctly when it\'s overflowed', (WidgetTester tester) async {
const String string = '👨‍👩‍👦0123456';
await setupWidget(tester, LengthLimitingTextInputFormatter(maxLength));
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're not at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: string));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Clearing composing range if collapsed.
state.updateEditingValue(const TextEditingValue(text: string, composing: TextRange(start: 10, end: 10)));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Clearing composing range if overflowed.
state.updateEditingValue(const TextEditingValue(text: string, composing: TextRange(start: 10, end: 11)));
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
// Regression test for https://github.com/flutter/flutter/issues/68086.
testWidgets('typing in the middle with different platforms.', (WidgetTester tester) async {
await setupWidget(tester, LengthLimitingTextInputFormatter(maxLength));
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
// Initially we're not at maxLength with no composing text.
state.updateEditingValue(const TextEditingValue(text: 'abc'));
expect(state.currentTextEditingValue.composing, TextRange.empty);
// Start typing in the middle.
state.updateEditingValue(const TextEditingValue(text: 'abDEc', composing: TextRange(start: 3, end: 4)));
expect(state.currentTextEditingValue.text, 'abDEc');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 4));
// Keep typing when the value has exceed the limitation.
state.updateEditingValue(const TextEditingValue(text: 'abDEFc', composing: TextRange(start: 3, end: 5)));
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia
) {
expect(state.currentTextEditingValue.text, 'abDEFc');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 5));
} else {
expect(state.currentTextEditingValue.text, 'abDEc');
expect(state.currentTextEditingValue.composing, const TextRange(start: 3, end: 4));
}
// Reset the value according to the limit.
state.updateEditingValue(const TextEditingValue(text: 'abDEc'));
expect(state.currentTextEditingValue.text, 'abDEc');
expect(state.currentTextEditingValue.composing, TextRange.empty);
state.updateEditingValue(const TextEditingValue(text: 'abDEFc', composing: TextRange(start: 4, end: 5)));
expect(state.currentTextEditingValue.composing, TextRange.empty);
});
}); });
group('callback errors', () { group('callback errors', () {
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() { void main() {
testWidgets('onSaved callback is called', (WidgetTester tester) async { testWidgets('onSaved callback is called', (WidgetTester tester) async {
...@@ -848,7 +849,7 @@ void main() { ...@@ -848,7 +849,7 @@ void main() {
expect(() => builder(), throwsAssertionError); expect(() => builder(), throwsAssertionError);
}); });
// Regression test for https://github.com/flutter/flutter/issues/65374. // Regression test for https://github.com/flutter/flutter/issues/63753.
testWidgets('Validate form should return correct validation if the value is composing', (WidgetTester tester) async { testWidgets('Validate form should return correct validation if the value is composing', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = GlobalKey<FormState>(); final GlobalKey<FormState> formKey = GlobalKey<FormState>();
String? fieldValue; String? fieldValue;
...@@ -864,6 +865,7 @@ void main() { ...@@ -864,6 +865,7 @@ void main() {
key: formKey, key: formKey,
child: TextFormField( child: TextFormField(
maxLength: 5, maxLength: 5,
maxLengthEnforcement: MaxLengthEnforcement.truncateAfterCompositionEnds,
onSaved: (String? value) { fieldValue = value; }, onSaved: (String? value) { fieldValue = value; },
validator: (String? value) => (value != null && value.length > 5) ? 'Exceeded' : null, validator: (String? value) => (value != null && value.length > 5) ? 'Exceeded' : null,
), ),
......
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