Text field height attempt 2 (#29250)
Adds the `minLines` and `expands` parameters for controlling text height. The original PR was reverted, so this one contains a few extra fixes for the tests that were broken.
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index 98b26f3..4f2a3b4 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -140,6 +140,8 @@
///
/// See also:
///
+ /// * [minLines]
+ /// * [expands], to allow the widget to size itself to its parent's height.
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
const CupertinoTextField({
@@ -164,6 +166,8 @@
this.obscureText = false,
this.autocorrect = true,
this.maxLines = 1,
+ this.minLines,
+ this.expands = false,
this.maxLength,
this.maxLengthEnforced = true,
this.onChanged,
@@ -183,6 +187,16 @@
assert(maxLengthEnforced != null),
assert(scrollPadding != null),
assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ 'minLines can\'t be greater than maxLines',
+ ),
+ assert(expands != null),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
assert(maxLength == null || maxLength > 0),
assert(clearButtonMode != null),
assert(prefixMode != null),
@@ -290,6 +304,12 @@
/// {@macro flutter.widgets.editableText.maxLines}
final int maxLines;
+ /// {@macro flutter.widgets.editableText.minLines}
+ final int minLines;
+
+ /// {@macro flutter.widgets.editableText.expands}
+ final bool expands;
+
/// The maximum number of characters (Unicode scalar values) to allow in the
/// text field.
///
@@ -405,6 +425,8 @@
properties.add(DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: false));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 1));
+ properties.add(IntProperty('minLines', minLines, defaultValue: null));
+ properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, ifTrue: 'max length enforced'));
properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
@@ -662,6 +684,8 @@
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
maxLines: widget.maxLines,
+ minLines: widget.minLines,
+ expands: widget.expands,
selectionColor: _kSelectionHighlightColor,
selectionControls: cupertinoTextSelectionControls,
onChanged: widget.onChanged,
diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart
index ca11f11..a561a48 100644
--- a/packages/flutter/lib/src/material/input_decorator.dart
+++ b/packages/flutter/lib/src/material/input_decorator.dart
@@ -552,14 +552,18 @@
@required TextDirection textDirection,
@required TextBaseline textBaseline,
@required bool isFocused,
+ @required bool expands,
}) : assert(decoration != null),
assert(textDirection != null),
assert(textBaseline != null),
+ assert(expands != null),
_decoration = decoration,
_textDirection = textDirection,
_textBaseline = textBaseline,
- _isFocused = isFocused;
+ _isFocused = isFocused,
+ _expands = expands;
+ static const double subtextGap = 8.0;
final Map<_DecorationSlot, RenderBox> slotToChild = <_DecorationSlot, RenderBox>{};
final Map<RenderBox, _DecorationSlot> childToSlot = <RenderBox, _DecorationSlot>{};
@@ -709,6 +713,16 @@
markNeedsSemanticsUpdate();
}
+ bool get expands => _expands;
+ bool _expands = false;
+ set expands(bool value) {
+ assert(value != null);
+ if (_expands == value)
+ return;
+ _expands = value;
+ markNeedsLayout();
+ }
+
@override
void attach(PipelineOwner owner) {
super.attach(owner);
@@ -804,34 +818,31 @@
EdgeInsets get contentPadding => decoration.contentPadding;
- // Returns a value used by performLayout to position all
- // of the renderers. This method applies layout to all of the renderers
- // except the container. For convenience, the container is laid out
- // in performLayout().
- _RenderDecorationLayout _layout(BoxConstraints layoutConstraints) {
- final Map<RenderBox, double> boxToBaseline = <RenderBox, double>{};
- BoxConstraints boxConstraints = layoutConstraints.loosen();
- double aboveBaseline = 0.0;
- double belowBaseline = 0.0;
- void layoutLineBox(RenderBox box) {
- if (box == null)
- return;
- box.layout(boxConstraints, parentUsesSize: true);
- final double baseline = box.getDistanceToBaseline(textBaseline);
- assert(baseline != null && baseline >= 0.0);
- boxToBaseline[box] = baseline;
- aboveBaseline = math.max(baseline, aboveBaseline);
- belowBaseline = math.max(box.size.height - baseline, belowBaseline);
+ // Lay out the given box if needed, and return its baseline
+ double _layoutLineBox(RenderBox box, BoxConstraints constraints) {
+ if (box == null) {
+ return 0.0;
}
- layoutLineBox(prefix);
- layoutLineBox(suffix);
+ box.layout(constraints, parentUsesSize: true);
+ final double baseline = box.getDistanceToBaseline(textBaseline);
+ assert(baseline != null && baseline >= 0.0);
+ return baseline;
+ }
- if (icon != null)
- icon.layout(boxConstraints, parentUsesSize: true);
- if (prefixIcon != null)
- prefixIcon.layout(boxConstraints, parentUsesSize: true);
- if (suffixIcon != null)
- suffixIcon.layout(boxConstraints, parentUsesSize: true);
+ // Returns a value used by performLayout to position all of the renderers.
+ // This method applies layout to all of the renderers except the container.
+ // For convenience, the container is laid out in performLayout().
+ _RenderDecorationLayout _layout(BoxConstraints layoutConstraints) {
+ // Margin on each side of subtext (counter and helperError)
+ final Map<RenderBox, double> boxToBaseline = <RenderBox, double>{};
+ final BoxConstraints boxConstraints = layoutConstraints.loosen();
+
+ // Layout all the widgets used by InputDecorator
+ boxToBaseline[prefix] = _layoutLineBox(prefix, boxConstraints);
+ boxToBaseline[suffix] = _layoutLineBox(suffix, boxConstraints);
+ boxToBaseline[icon] = _layoutLineBox(icon, boxConstraints);
+ boxToBaseline[prefixIcon] = _layoutLineBox(prefixIcon, boxConstraints);
+ boxToBaseline[suffixIcon] = _layoutLineBox(suffixIcon, boxConstraints);
final double inputWidth = math.max(0.0, constraints.maxWidth - (
_boxSize(icon).width
@@ -841,72 +852,144 @@
+ _boxSize(suffix).width
+ _boxSize(suffixIcon).width
+ contentPadding.right));
+ boxToBaseline[label] = _layoutLineBox(
+ label,
+ boxConstraints.copyWith(maxWidth: inputWidth),
+ );
+ boxToBaseline[hint] = _layoutLineBox(
+ hint,
+ boxConstraints.copyWith(minWidth: inputWidth, maxWidth: inputWidth),
+ );
+ boxToBaseline[counter] = _layoutLineBox(counter, boxConstraints);
- boxConstraints = boxConstraints.copyWith(maxWidth: inputWidth);
- if (label != null) {
- if (decoration.alignLabelWithHint) {
- // The label is aligned with the hint, at the baseline
- layoutLineBox(label);
- } else {
- // The label is centered, not baseline aligned
- label.layout(boxConstraints, parentUsesSize: true);
- }
- }
-
- boxConstraints = boxConstraints.copyWith(minWidth: inputWidth);
- layoutLineBox(hint);
- layoutLineBox(input);
-
- double inputBaseline = contentPadding.top + aboveBaseline;
- double containerHeight = contentPadding.top
- + aboveBaseline
- + belowBaseline
- + contentPadding.bottom;
-
- if (label != null) {
- // floatingLabelHeight includes the vertical gap between the inline
- // elements and the floating label.
- containerHeight += decoration.floatingLabelHeight;
- inputBaseline += decoration.floatingLabelHeight;
- }
-
- containerHeight = math.max(
- containerHeight,
- math.max(
- _boxSize(suffixIcon).height,
- _boxSize(prefixIcon).height));
-
- // Inline text within an outline border is centered within the container
- // less 2.0 dps at the top to account for the vertical space occupied
- // by the floating label.
- final double outlineBaseline = aboveBaseline +
- (containerHeight - (2.0 + aboveBaseline + belowBaseline)) / 2.0;
-
- double subtextBaseline = 0.0;
- double subtextHeight = 0.0;
- if (helperError != null || counter != null) {
- boxConstraints = layoutConstraints.loosen();
- aboveBaseline = 0.0;
- belowBaseline = 0.0;
- layoutLineBox(counter);
-
- // The helper or error text can occupy the full width less the space
- // occupied by the icon and counter.
- boxConstraints = boxConstraints.copyWith(
+ // The helper or error text can occupy the full width less the space
+ // occupied by the icon and counter.
+ boxToBaseline[helperError] = _layoutLineBox(
+ helperError,
+ boxConstraints.copyWith(
maxWidth: math.max(0.0, boxConstraints.maxWidth
- _boxSize(icon).width
- _boxSize(counter).width
- contentPadding.horizontal,
),
- );
- layoutLineBox(helperError);
+ ),
+ );
- if (aboveBaseline + belowBaseline > 0.0) {
- const double subtextGap = 8.0;
- subtextBaseline = containerHeight + subtextGap + aboveBaseline;
- subtextHeight = subtextGap + aboveBaseline + belowBaseline;
- }
+ // The height of the input needs to accommodate label above and counter and
+ // helperError below, when they exist.
+ final double labelHeight = label == null
+ ? 0
+ : decoration.floatingLabelHeight;
+ final double topHeight = decoration.border.isOutline
+ ? math.max(labelHeight - boxToBaseline[label], 0)
+ : labelHeight;
+ final double counterHeight = counter == null
+ ? 0
+ : boxToBaseline[counter] + subtextGap;
+ final bool helperErrorExists = helperError?.size != null
+ && helperError.size.height > 0;
+ final double helperErrorHeight = !helperErrorExists
+ ? 0
+ : helperError.size.height + subtextGap;
+ final double bottomHeight = math.max(
+ counterHeight,
+ helperErrorHeight,
+ );
+ boxToBaseline[input] = _layoutLineBox(
+ input,
+ boxConstraints.deflate(EdgeInsets.only(
+ top: contentPadding.top + topHeight,
+ bottom: contentPadding.bottom + bottomHeight,
+ )).copyWith(
+ minWidth: inputWidth,
+ maxWidth: inputWidth,
+ ),
+ );
+
+ // The field can be occupied by a hint or by the input itself
+ final double hintHeight = hint == null ? 0 : hint.size.height;
+ final double inputDirectHeight = input == null ? 0 : input.size.height;
+ final double inputHeight = math.max(hintHeight, inputDirectHeight);
+ final double inputInternalBaseline = math.max(
+ boxToBaseline[input],
+ boxToBaseline[hint],
+ );
+
+ // Calculate the amount that prefix/suffix affects height above and below
+ // the input.
+ final double prefixHeight = prefix == null ? 0 : prefix.size.height;
+ final double suffixHeight = suffix == null ? 0 : suffix.size.height;
+ final double fixHeight = math.max(
+ boxToBaseline[prefix],
+ boxToBaseline[suffix],
+ );
+ final double fixAboveInput = math.max(0, fixHeight - inputInternalBaseline);
+ final double fixBelowBaseline = math.max(
+ prefixHeight - boxToBaseline[prefix],
+ suffixHeight - boxToBaseline[suffix],
+ );
+ final double fixBelowInput = math.max(
+ 0,
+ fixBelowBaseline - (inputHeight - inputInternalBaseline),
+ );
+
+ // Calculate the height of the input text container.
+ final double prefixIconHeight = prefixIcon == null ? 0 : prefixIcon.size.height;
+ final double suffixIconHeight = suffixIcon == null ? 0 : suffixIcon.size.height;
+ final double fixIconHeight = math.max(prefixIconHeight, suffixIconHeight);
+ final double contentHeight = math.max(
+ fixIconHeight,
+ topHeight
+ + contentPadding.top
+ + fixAboveInput
+ + inputHeight
+ + fixBelowInput
+ + contentPadding.bottom,
+ );
+ final double maxContainerHeight = boxConstraints.maxHeight - bottomHeight;
+ final double containerHeight = expands
+ ? maxContainerHeight
+ : math.min(contentHeight, maxContainerHeight);
+
+ // Always position the prefix/suffix in the same place (baseline).
+ final double overflow = math.max(0, contentHeight - maxContainerHeight);
+ final double baselineAdjustment = fixAboveInput - overflow;
+
+ // The baselines that will be used to draw the actual input text content.
+ final double inputBaseline = contentPadding.top
+ + topHeight
+ + inputInternalBaseline
+ + baselineAdjustment;
+ // The text in the input when an outline border is present is centered
+ // within the container less 2.0 dps at the top to account for the vertical
+ // space occupied by the floating label.
+ final double outlineBaseline = inputInternalBaseline
+ + baselineAdjustment / 2
+ + (containerHeight - (2.0 + inputHeight)) / 2.0;
+
+ // Find the positions of the text below the input when it exists.
+ double subtextCounterBaseline = 0;
+ double subtextHelperBaseline = 0;
+ double subtextCounterHeight = 0;
+ double subtextHelperHeight = 0;
+ if (counter != null) {
+ subtextCounterBaseline =
+ containerHeight + subtextGap + boxToBaseline[counter];
+ subtextCounterHeight = counter.size.height + subtextGap;
}
+ if (helperErrorExists) {
+ subtextHelperBaseline =
+ containerHeight + subtextGap + boxToBaseline[helperError];
+ subtextHelperHeight = helperErrorHeight;
+ }
+ final double subtextBaseline = math.max(
+ subtextCounterBaseline,
+ subtextHelperBaseline,
+ );
+ final double subtextHeight = math.max(
+ subtextCounterHeight,
+ subtextHelperHeight,
+ );
return _RenderDecorationLayout(
boxToBaseline: boxToBaseline,
@@ -959,7 +1042,7 @@
double computeMinIntrinsicHeight(double width) {
double subtextHeight = _lineHeight(width, <RenderBox>[helperError, counter]);
if (subtextHeight > 0.0)
- subtextHeight += 8.0;
+ subtextHeight += subtextGap;
return contentPadding.top
+ (label == null ? 0.0 : decoration.floatingLabelHeight)
+ _lineHeight(width, <RenderBox>[prefix, input, suffix])
@@ -1370,15 +1453,18 @@
@required this.textDirection,
@required this.textBaseline,
@required this.isFocused,
+ @required this.expands,
}) : assert(decoration != null),
assert(textDirection != null),
assert(textBaseline != null),
+ assert(expands != null),
super(key: key);
final _Decoration decoration;
final TextDirection textDirection;
final TextBaseline textBaseline;
final bool isFocused;
+ final bool expands;
@override
_RenderDecorationElement createElement() => _RenderDecorationElement(this);
@@ -1390,6 +1476,7 @@
textDirection: textDirection,
textBaseline: textBaseline,
isFocused: isFocused,
+ expands: expands,
);
}
@@ -1399,6 +1486,7 @@
..decoration = decoration
..textDirection = textDirection
..textBaseline = textBaseline
+ ..expands = expands
..isFocused = isFocused;
}
}
@@ -1461,6 +1549,7 @@
this.baseStyle,
this.textAlign,
this.isFocused = false,
+ this.expands = false,
this.isEmpty = false,
this.child,
}) : assert(isFocused != null),
@@ -1495,6 +1584,19 @@
/// Defaults to false.
final bool isFocused;
+ /// If true, the height of the input field will be as large as possible.
+ ///
+ /// If wrapped in a widget that constrains its child's height, like Expanded
+ /// or SizedBox, the input field will only be affected if [expands] is set to
+ /// true.
+ ///
+ /// See [TextField.minLines] and [TextField.maxLines] for related ways to
+ /// affect the height of an input. When [expands] is true, both must be null
+ /// in order to avoid ambiguity in determining the height.
+ ///
+ /// Defaults to false.
+ final bool expands;
+
/// Whether the input field is empty.
///
/// Determines the position of the label text and whether to display the hint
@@ -1533,6 +1635,7 @@
properties.add(DiagnosticsProperty<InputDecoration>('decoration', decoration));
properties.add(DiagnosticsProperty<TextStyle>('baseStyle', baseStyle, defaultValue: null));
properties.add(DiagnosticsProperty<bool>('isFocused', isFocused));
+ properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('isEmpty', isEmpty));
}
}
@@ -1928,6 +2031,7 @@
textDirection: textDirection,
textBaseline: textBaseline,
isFocused: isFocused,
+ expands: widget.expands,
);
}
}
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 9b8b141..6399488 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -147,6 +147,8 @@
this.obscureText = false,
this.autocorrect = true,
this.maxLines = 1,
+ this.minLines,
+ this.expands = false,
this.maxLength,
this.maxLengthEnforced = true,
this.onChanged,
@@ -171,6 +173,16 @@
assert(scrollPadding != null),
assert(dragStartBehavior != null),
assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ 'minLines can\'t be greater than maxLines',
+ ),
+ assert(expands != null),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
assert(maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
super(key: key);
@@ -269,6 +281,12 @@
/// {@macro flutter.widgets.editableText.maxLines}
final int maxLines;
+ /// {@macro flutter.widgets.editableText.minLines}
+ final int minLines;
+
+ /// {@macro flutter.widgets.editableText.expands}
+ final bool expands;
+
/// If [maxLength] is set to this value, only the "current input length"
/// part of the character counter is shown.
static const int noMaxLength = -1;
@@ -457,6 +475,8 @@
properties.add(DiagnosticsProperty<bool>('obscureText', obscureText, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect, defaultValue: true));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 1));
+ properties.add(IntProperty('minLines', minLines, defaultValue: null));
+ properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(FlagProperty('maxLengthEnforced', value: maxLengthEnforced, defaultValue: true, ifFalse: 'maxLength not enforced'));
properties.add(EnumProperty<TextInputAction>('textInputAction', textInputAction, defaultValue: null));
@@ -851,6 +871,8 @@
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
maxLines: widget.maxLines,
+ minLines: widget.minLines,
+ expands: widget.expands,
selectionColor: themeData.textSelectionColor,
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
onChanged: widget.onChanged,
@@ -883,6 +905,7 @@
textAlign: widget.textAlign,
isFocused: focusNode.hasFocus,
isEmpty: controller.value.text.isEmpty,
+ expands: widget.expands,
child: child,
);
},
diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart
index 3db75f9..1362e51 100644
--- a/packages/flutter/lib/src/material/text_form_field.dart
+++ b/packages/flutter/lib/src/material/text_form_field.dart
@@ -89,6 +89,8 @@
bool autovalidate = false,
bool maxLengthEnforced = true,
int maxLines = 1,
+ int minLines,
+ bool expands = false,
int maxLength,
VoidCallback onEditingComplete,
ValueChanged<String> onFieldSubmitted,
@@ -112,6 +114,16 @@
assert(maxLengthEnforced != null),
assert(scrollPadding != null),
assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ 'minLines can\'t be greater than maxLines',
+ ),
+ assert(expands != null),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
assert(maxLength == null || maxLength > 0),
assert(enableInteractiveSelection != null),
super(
@@ -141,6 +153,8 @@
autocorrect: autocorrect,
maxLengthEnforced: maxLengthEnforced,
maxLines: maxLines,
+ minLines: minLines,
+ expands: expands,
maxLength: maxLength,
onChanged: field.didChange,
onEditingComplete: onEditingComplete,
diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart
index eb4d494..283d734 100644
--- a/packages/flutter/lib/src/rendering/box.dart
+++ b/packages/flutter/lib/src/rendering/box.dart
@@ -1801,9 +1801,9 @@
testIntrinsicsForValues(getMinIntrinsicWidth, getMaxIntrinsicWidth, 'Width', double.infinity);
testIntrinsicsForValues(getMinIntrinsicHeight, getMaxIntrinsicHeight, 'Height', double.infinity);
if (constraints.hasBoundedWidth)
- testIntrinsicsForValues(getMinIntrinsicWidth, getMaxIntrinsicWidth, 'Width', constraints.maxWidth);
+ testIntrinsicsForValues(getMinIntrinsicWidth, getMaxIntrinsicWidth, 'Width', constraints.maxHeight);
if (constraints.hasBoundedHeight)
- testIntrinsicsForValues(getMinIntrinsicHeight, getMaxIntrinsicHeight, 'Height', constraints.maxHeight);
+ testIntrinsicsForValues(getMinIntrinsicHeight, getMaxIntrinsicHeight, 'Height', constraints.maxWidth);
// TODO(ianh): Test that values are internally consistent in more ways than the above.
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 9ad43f0..2485239 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -144,6 +144,8 @@
ValueNotifier<bool> showCursor,
bool hasFocus,
int maxLines = 1,
+ int minLines,
+ bool expands = false,
StrutStyle strutStyle,
Color selectionColor,
double textScaleFactor = 1.0,
@@ -165,6 +167,16 @@
}) : assert(textAlign != null),
assert(textDirection != null, 'RenderEditable created without a textDirection.'),
assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ 'minLines can\'t be greater than maxLines',
+ ),
+ assert(expands != null),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
assert(textScaleFactor != null),
assert(offset != null),
assert(ignorePointer != null),
@@ -186,6 +198,8 @@
_showCursor = showCursor ?? ValueNotifier<bool>(false),
_hasFocus = hasFocus ?? false,
_maxLines = maxLines,
+ _minLines = minLines,
+ _expands = expands,
_selectionColor = selectionColor,
_selection = selection,
_offset = offset,
@@ -735,6 +749,29 @@
markNeedsTextLayout();
}
+ /// {@macro flutter.widgets.editableText.minLines}
+ int get minLines => _minLines;
+ int _minLines;
+ /// The value may be null. If it is not null, then it must be greater than zero.
+ set minLines(int value) {
+ assert(value == null || value > 0);
+ if (minLines == value)
+ return;
+ _minLines = value;
+ markNeedsTextLayout();
+ }
+
+ /// {@macro flutter.widgets.editableText.expands}
+ bool get expands => _expands;
+ bool _expands;
+ set expands(bool value) {
+ assert(value != null);
+ if (expands == value)
+ return;
+ _expands = value;
+ markNeedsTextLayout();
+ }
+
/// The color to use when painting the selection.
Color get selectionColor => _selectionColor;
Color _selectionColor;
@@ -1194,8 +1231,28 @@
double get preferredLineHeight => _textPainter.preferredLineHeight;
double _preferredHeight(double width) {
- if (maxLines != null)
+ // Lock height to maxLines if needed
+ final bool lockedMax = maxLines != null && minLines == null;
+ final bool lockedBoth = minLines != null && minLines == maxLines;
+ final bool singleLine = maxLines == 1;
+ if (singleLine || lockedMax || lockedBoth) {
return preferredLineHeight * maxLines;
+ }
+
+ // Clamp height to minLines or maxLines if needed
+ final bool minLimited = minLines != null && minLines > 1;
+ final bool maxLimited = maxLines != null;
+ if (minLimited || maxLimited) {
+ _layoutText(width);
+ if (minLimited && _textPainter.height < preferredLineHeight * minLines) {
+ return preferredLineHeight * minLines;
+ }
+ if (maxLimited && _textPainter.height > preferredLineHeight * maxLines) {
+ return preferredLineHeight * maxLines;
+ }
+ }
+
+ // Set the height based on the content
if (width == double.infinity) {
final String text = _textPainter.text.toPlainText();
int lines = 1;
@@ -1658,6 +1715,8 @@
properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor));
properties.add(DiagnosticsProperty<ValueNotifier<bool>>('showCursor', showCursor));
properties.add(IntProperty('maxLines', maxLines));
+ properties.add(IntProperty('minLines', minLines));
+ properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(DiagnosticsProperty<Color>('selectionColor', selectionColor));
properties.add(DoubleProperty('textScaleFactor', textScaleFactor));
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 1012034..d6a0b04 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -278,6 +278,8 @@
this.locale,
this.textScaleFactor,
this.maxLines = 1,
+ this.minLines,
+ this.expands = false,
this.autofocus = false,
this.selectionColor,
this.selectionControls,
@@ -310,6 +312,16 @@
assert(backgroundCursorColor != null),
assert(textAlign != null),
assert(maxLines == null || maxLines > 0),
+ assert(minLines == null || minLines > 0),
+ assert(
+ (maxLines == null) || (minLines == null) || (maxLines >= minLines),
+ 'minLines can\'t be greater than maxLines',
+ ),
+ assert(expands != null),
+ assert(
+ !expands || (maxLines == null && minLines == null),
+ 'minLines and maxLines must be null when expands is true.',
+ ),
assert(autofocus != null),
assert(rendererIgnoresPointer != null),
assert(scrollPadding != null),
@@ -465,12 +477,78 @@
/// container will start with enough vertical space for one line and
/// automatically grow to accommodate additional lines as they are entered.
///
- /// If it is not null, the value must be greater than zero. If it is greater
- /// than 1, it will take up enough horizontal space to accommodate that number
- /// of lines.
+ /// If this is not null, the value must be greater than zero, and it will lock
+ /// the input to the given number of lines and take up enough horizontal space
+ /// to accommodate that number of lines. Setting [minLines] as well allows the
+ /// input to grow between the indicated range.
+ ///
+ /// The full set of behaviors possible with [minLines] and [maxLines] are as
+ /// follows. These examples apply equally to `TextField`, `TextFormField`, and
+ /// `EditableText`.
+ ///
+ /// Input that occupies a single line and scrolls horizontally as needed.
+ /// ```dart
+ /// TextField()
+ /// ```
+ ///
+ /// Input whose height grows from one line up to as many lines as needed for
+ /// the text that was entered. If a height limit is imposed by its parent, it
+ /// will scroll vertically when its height reaches that limit.
+ /// ```dart
+ /// TextField(maxLines: null)
+ /// ```
+ ///
+ /// The input's height is large enough for the given number of lines. If
+ /// additional lines are entered the input scrolls vertically.
+ /// ```dart
+ /// TextField(maxLines: 2)
+ /// ```
+ ///
+ /// Input whose height grows with content between a min and max. An infinite
+ /// max is possible with `maxLines: null`.
+ /// ```dart
+ /// TextField(minLines: 2, maxLines: 4)
+ /// ```
/// {@endtemplate}
final int maxLines;
+ /// {@template flutter.widgets.editableText.minLines}
+ /// The minimum number of lines to occupy when the content spans fewer lines.
+
+ /// When [maxLines] is set as well, the height will grow between the indicated
+ /// range of lines. When [maxLines] is null, it will grow as high as needed,
+ /// starting from [minLines].
+ ///
+ /// See the examples in [maxLines] for the complete picture of how [maxLines]
+ /// and [minLines] interact to produce various behaviors.
+ ///
+ /// Defaults to null.
+ /// {@endtemplate}
+ final int minLines;
+
+ /// {@template flutter.widgets.editableText.expands}
+ /// Whether this widget's height will be sized to fill its parent.
+ ///
+ /// If set to true and wrapped in a parent widget like [Expanded] or
+ /// [SizedBox], the input will expand to fill the parent.
+ ///
+ /// [maxLines] and [minLines] must both be null when this is set to true,
+ /// otherwise an error is thrown.
+ ///
+ /// Defaults to false.
+ ///
+ /// See the examples in [maxLines] for the complete picture of how [maxLines],
+ /// [minLines], and [expands] interact to produce various behaviors.
+ ///
+ /// Input that matches the height of its parent
+ /// ```dart
+ /// Expanded(
+ /// child: TextField(maxLines: null, expands: true),
+ /// )
+ /// ```
+ /// {@endtemplate}
+ final bool expands;
+
/// {@template flutter.widgets.editableText.autofocus}
/// Whether this text field should focus itself if nothing else is already
/// focused.
@@ -676,6 +754,8 @@
properties.add(DiagnosticsProperty<Locale>('locale', locale, defaultValue: null));
properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 1));
+ properties.add(IntProperty('minLines', minLines, defaultValue: null));
+ properties.add(DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
properties.add(DiagnosticsProperty<TextInputType>('keyboardType', keyboardType, defaultValue: null));
}
@@ -795,7 +875,7 @@
// If this is a multiline EditableText, do nothing for a "newline"
// action; The newline is already inserted. Otherwise, finalize
// editing.
- if (widget.maxLines == 1)
+ if (!_isMultiline)
_finalizeEditing(true);
break;
case TextInputAction.done:
@@ -1333,6 +1413,8 @@
: _cursorVisibilityNotifier,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
+ minLines: widget.minLines,
+ expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: widget.selectionColor,
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
@@ -1403,6 +1485,8 @@
this.showCursor,
this.hasFocus,
this.maxLines,
+ this.minLines,
+ this.expands,
this.strutStyle,
this.selectionColor,
this.textScaleFactor,
@@ -1433,6 +1517,8 @@
final ValueNotifier<bool> showCursor;
final bool hasFocus;
final int maxLines;
+ final int minLines;
+ final bool expands;
final StrutStyle strutStyle;
final Color selectionColor;
final double textScaleFactor;
@@ -1462,6 +1548,8 @@
showCursor: showCursor,
hasFocus: hasFocus,
maxLines: maxLines,
+ minLines: minLines,
+ expands: expands,
strutStyle: strutStyle,
selectionColor: selectionColor,
textScaleFactor: textScaleFactor,
@@ -1492,6 +1580,8 @@
..showCursor = showCursor
..hasFocus = hasFocus
..maxLines = maxLines
+ ..minLines = minLines
+ ..expands = expands
..strutStyle = strutStyle
..selectionColor = selectionColor
..textScaleFactor = textScaleFactor
diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart
index 101a1eb..8e8cba8 100644
--- a/packages/flutter/test/material/input_decorator_test.dart
+++ b/packages/flutter/test/material/input_decorator_test.dart
@@ -993,6 +993,100 @@
expect(tester.getTopRight(find.text('text')).dx, lessThanOrEqualTo(tester.getTopRight(find.byKey(sKey)).dx));
});
+ testWidgets('InputDecorator tall prefix', (WidgetTester tester) async {
+ const Key pKey = Key('p');
+ await tester.pumpWidget(
+ buildInputDecorator(
+ // isEmpty: false (default)
+ // isFocused: false (default)
+ decoration: InputDecoration(
+ prefix: Container(
+ key: pKey,
+ height: 100,
+ width: 10,
+ ),
+ filled: true,
+ ),
+ // Set the fontSize so that everything works out to whole numbers.
+ child: const Text(
+ 'text',
+ style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+ ),
+ ),
+ );
+
+ // Overall height for this InputDecorator is ~127.2dps because
+ // the prefix is 100dps tall, but it aligns with the input's baseline,
+ // overlapping the input a bit.
+ // 12 - top padding
+ // 100 - total height of prefix
+ // -16 - input prefix overlap (distance input top to baseline, not exact)
+ // 20 - input text (ahem font size 16dps)
+ // 0 - bottom prefix/suffix padding
+ // 12 - bottom padding
+
+ expect(tester.getSize(find.byType(InputDecorator)).width, 800.0);
+ expect(tester.getSize(find.byType(InputDecorator)).height, closeTo(128.0, .0001));
+ expect(tester.getSize(find.text('text')).height, 20.0);
+ expect(tester.getSize(find.byKey(pKey)).height, 100.0);
+ expect(tester.getTopLeft(find.text('text')).dy, closeTo(96, .0001)); // 12 + 100 - 16
+ expect(tester.getTopLeft(find.byKey(pKey)).dy, 12.0);
+
+ // layout is a row: [prefix text suffix]
+ expect(tester.getTopLeft(find.byKey(pKey)).dx, 12.0);
+ expect(tester.getTopRight(find.byKey(pKey)).dx, tester.getTopLeft(find.text('text')).dx);
+ });
+
+ testWidgets('InputDecorator tall prefix with border', (WidgetTester tester) async {
+ const Key pKey = Key('p');
+ await tester.pumpWidget(
+ buildInputDecorator(
+ // isEmpty: false (default)
+ // isFocused: false (default)
+ decoration: InputDecoration(
+ border: const OutlineInputBorder(),
+ prefix: Container(
+ key: pKey,
+ height: 100,
+ width: 10,
+ ),
+ filled: true,
+ ),
+ // Set the fontSize so that everything works out to whole numbers.
+ child: const Text(
+ 'text',
+ style: TextStyle(fontFamily: 'Ahem', fontSize: 20.0),
+ ),
+ ),
+ );
+
+ // Overall height for this InputDecorator is ~127.2dps because
+ // the prefix is 100dps tall, but it aligns with the input's baseline,
+ // overlapping the input a bit.
+ // 24 - top padding
+ // 100 - total height of prefix
+ // -16 - input prefix overlap (distance input top to baseline, not exact)
+ // 20 - input text (ahem font size 16dps)
+ // 0 - bottom prefix/suffix padding
+ // 16 - bottom padding
+ // When a border is present, the input text and prefix/suffix are centered
+ // within the input. Here, that will be content of height 106, including 2
+ // extra pixels of space, centered within an input of height 144. That gives
+ // 19 pixels of space on each side of the content, so the prefix is
+ // positioned at 19, and the text is at 19+100-16=103.
+
+ expect(tester.getSize(find.byType(InputDecorator)).width, 800.0);
+ expect(tester.getSize(find.byType(InputDecorator)).height, closeTo(144, .0001));
+ expect(tester.getSize(find.text('text')).height, 20.0);
+ expect(tester.getSize(find.byKey(pKey)).height, 100.0);
+ expect(tester.getTopLeft(find.text('text')).dy, closeTo(103, .0001));
+ expect(tester.getTopLeft(find.byKey(pKey)).dy, 19.0);
+
+ // layout is a row: [prefix text suffix]
+ expect(tester.getTopLeft(find.byKey(pKey)).dx, 12.0);
+ expect(tester.getTopRight(find.byKey(pKey)).dx, tester.getTopLeft(find.text('text')).dx);
+ });
+
testWidgets('InputDecorator prefixIcon/suffixIcon', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
@@ -1075,7 +1169,6 @@
expect(tester.getTopLeft(find.byKey(prefixKey)).dy, 0.0);
});
-
testWidgets('counter text has correct right margin - LTR, not dense', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
@@ -1463,12 +1556,12 @@
testWidgets('InputDecoration outline shape with no border and no floating placeholder', (WidgetTester tester) async {
await tester.pumpWidget(
buildInputDecorator(
- // isFocused: false (default)
- isEmpty: true,
- decoration: const InputDecoration(
- border: OutlineInputBorder(borderSide: BorderSide.none),
- hasFloatingPlaceholder: false,
- labelText: 'label',
+ // isFocused: false (default)
+ isEmpty: true,
+ decoration: const InputDecoration(
+ border: OutlineInputBorder(borderSide: BorderSide.none),
+ hasFloatingPlaceholder: false,
+ labelText: 'label',
),
),
);
diff --git a/packages/flutter/test/material/text_field_focus_test.dart b/packages/flutter/test/material/text_field_focus_test.dart
index 68c1611..2ec69c5 100644
--- a/packages/flutter/test/material/text_field_focus_test.dart
+++ b/packages/flutter/test/material/text_field_focus_test.dart
@@ -6,6 +6,49 @@
import 'package:flutter/material.dart';
void main() {
+ testWidgets('Dialog interaction', (WidgetTester tester) async {
+ expect(tester.testTextInput.isVisible, isFalse);
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: TextField(
+ autofocus: true,
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.testTextInput.isVisible, isTrue);
+
+ final BuildContext context = tester.element(find.byType(TextField));
+
+ showDialog<void>(
+ context: context,
+ builder: (BuildContext context) => const SimpleDialog(title: Text('Dialog')),
+ );
+
+ await tester.pump();
+
+ expect(tester.testTextInput.isVisible, isFalse);
+
+ Navigator.of(tester.element(find.text('Dialog'))).pop();
+ await tester.pump();
+
+ expect(tester.testTextInput.isVisible, isFalse);
+
+ await tester.tap(find.byType(TextField));
+ await tester.idle();
+
+ expect(tester.testTextInput.isVisible, isTrue);
+
+ await tester.pumpWidget(Container());
+
+ expect(tester.testTextInput.isVisible, isFalse);
+ });
+
testWidgets('Request focus shows keyboard', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
@@ -87,49 +130,6 @@
await tester.pumpWidget(Container());
expect(tester.testTextInput.isVisible, isFalse);
- });
-
- testWidgets('Dialog interaction', (WidgetTester tester) async {
- expect(tester.testTextInput.isVisible, isFalse);
-
- await tester.pumpWidget(
- const MaterialApp(
- home: Material(
- child: Center(
- child: TextField(
- autofocus: true,
- ),
- ),
- ),
- ),
- );
-
- expect(tester.testTextInput.isVisible, isTrue);
-
- final BuildContext context = tester.element(find.byType(TextField));
-
- showDialog<void>(
- context: context,
- builder: (BuildContext context) => const SimpleDialog(title: Text('Dialog')),
- );
-
- await tester.pump();
-
- expect(tester.testTextInput.isVisible, isFalse);
-
- Navigator.of(tester.element(find.text('Dialog'))).pop();
- await tester.pump();
-
- expect(tester.testTextInput.isVisible, isFalse);
-
- await tester.tap(find.byType(TextField));
- await tester.idle();
-
- expect(tester.testTextInput.isVisible, isTrue);
-
- await tester.pumpWidget(Container());
-
- expect(tester.testTextInput.isVisible, isFalse);
}, skip: true); // https://github.com/flutter/flutter/issues/29384.
testWidgets('Focus triggers keep-alive', (WidgetTester tester) async {
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 7cd6d3b..1e38ea3 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -174,6 +174,24 @@
debugResetSemanticsIdCounter();
});
+ final Key textFieldKey = UniqueKey();
+ Widget textFieldBuilder({
+ int maxLines = 1,
+ int minLines,
+ }) {
+ return boilerplate(
+ child: TextField(
+ key: textFieldKey,
+ style: const TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: maxLines,
+ minLines: minLines,
+ decoration: const InputDecoration(
+ hintText: 'Placeholder',
+ ),
+ ),
+ );
+ }
+
testWidgets('TextField passes onEditingComplete to EditableText', (WidgetTester tester) async {
final VoidCallback onEditingComplete = () { };
@@ -883,23 +901,8 @@
expect(controller.selection.isCollapsed, false);
});
- testWidgets('Multiline text will wrap up to maxLines', (WidgetTester tester) async {
- final Key textFieldKey = UniqueKey();
-
- Widget builder(int maxLines) {
- return boilerplate(
- child: TextField(
- key: textFieldKey,
- style: const TextStyle(color: Colors.black, fontSize: 34.0),
- maxLines: maxLines,
- decoration: const InputDecoration(
- hintText: 'Placeholder',
- ),
- ),
- );
- }
-
- await tester.pumpWidget(builder(null));
+ testWidgets('TextField height with minLines unset', (WidgetTester tester) async {
+ await tester.pumpWidget(textFieldBuilder());
RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey));
@@ -907,46 +910,361 @@
final Size emptyInputSize = inputBox.size;
await tester.enterText(find.byType(TextField), 'No wrapping here.');
- await tester.pumpWidget(builder(null));
+ await tester.pumpWidget(textFieldBuilder());
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, equals(emptyInputSize));
- await tester.pumpWidget(builder(3));
+ // Even when entering multiline text, TextField doesn't grow. It's a single
+ // line input.
+ await tester.enterText(find.byType(TextField), kThreeLines);
+ await tester.pumpWidget(textFieldBuilder());
expect(findInputBox(), equals(inputBox));
- expect(inputBox.size, greaterThan(emptyInputSize));
+ expect(inputBox.size, equals(emptyInputSize));
+
+ // maxLines: 3 makes the TextField 3 lines tall
+ await tester.enterText(find.byType(TextField), '');
+ await tester.pumpWidget(textFieldBuilder(maxLines: 3));
+ expect(findInputBox(), equals(inputBox));
+ expect(inputBox.size.height, greaterThan(emptyInputSize.height));
+ expect(inputBox.size.width, emptyInputSize.width);
final Size threeLineInputSize = inputBox.size;
+ // Filling with 3 lines of text stays the same size
await tester.enterText(find.byType(TextField), kThreeLines);
- await tester.pumpWidget(builder(null));
- expect(findInputBox(), equals(inputBox));
- expect(inputBox.size, greaterThan(emptyInputSize));
-
- await tester.enterText(find.byType(TextField), kThreeLines);
- await tester.pumpWidget(builder(null));
+ await tester.pumpWidget(textFieldBuilder(maxLines: 3));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, threeLineInputSize);
// An extra line won't increase the size because we max at 3.
await tester.enterText(find.byType(TextField), kMoreThanFourLines);
- await tester.pumpWidget(builder(3));
+ await tester.pumpWidget(textFieldBuilder(maxLines: 3));
expect(findInputBox(), equals(inputBox));
expect(inputBox.size, threeLineInputSize);
// But now it will... but it will max at four
await tester.enterText(find.byType(TextField), kMoreThanFourLines);
- await tester.pumpWidget(builder(4));
+ await tester.pumpWidget(textFieldBuilder(maxLines: 4));
expect(findInputBox(), equals(inputBox));
- expect(inputBox.size, greaterThan(threeLineInputSize));
+ expect(inputBox.size.height, greaterThan(threeLineInputSize.height));
+ expect(inputBox.size.width, threeLineInputSize.width);
final Size fourLineInputSize = inputBox.size;
// Now it won't max out until the end
- await tester.pumpWidget(builder(null));
+ await tester.enterText(find.byType(TextField), '');
+ await tester.pumpWidget(textFieldBuilder(maxLines: null));
expect(findInputBox(), equals(inputBox));
- expect(inputBox.size, greaterThan(fourLineInputSize));
+ expect(inputBox.size, equals(emptyInputSize));
+ await tester.enterText(find.byType(TextField), kThreeLines);
+ await tester.pump();
+ expect(inputBox.size, equals(threeLineInputSize));
+ await tester.enterText(find.byType(TextField), kMoreThanFourLines);
+ await tester.pump();
+ expect(inputBox.size.height, greaterThan(fourLineInputSize.height));
+ expect(inputBox.size.width, fourLineInputSize.width);
});
+ testWidgets('TextField height with minLines and maxLines', (WidgetTester tester) async {
+ await tester.pumpWidget(textFieldBuilder());
+
+ RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey));
+
+ final RenderBox inputBox = findInputBox();
+ final Size emptyInputSize = inputBox.size;
+
+ await tester.enterText(find.byType(TextField), 'No wrapping here.');
+ await tester.pumpWidget(textFieldBuilder());
+ expect(findInputBox(), equals(inputBox));
+ expect(inputBox.size, equals(emptyInputSize));
+
+ // min and max set to same value locks height to value.
+ await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 3));
+ expect(findInputBox(), equals(inputBox));
+ expect(inputBox.size.height, greaterThan(emptyInputSize.height));
+ expect(inputBox.size.width, emptyInputSize.width);
+
+ final Size threeLineInputSize = inputBox.size;
+
+ // maxLines: null with minLines set grows beyond minLines
+ await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: null));
+ expect(findInputBox(), equals(inputBox));
+ expect(inputBox.size, threeLineInputSize);
+ await tester.enterText(find.byType(TextField), kMoreThanFourLines);
+ await tester.pump();
+ expect(inputBox.size.height, greaterThan(threeLineInputSize.height));
+ expect(inputBox.size.width, threeLineInputSize.width);
+
+ // With minLines and maxLines set, input will expand through the range
+ await tester.enterText(find.byType(TextField), '');
+ await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 4));
+ expect(findInputBox(), equals(inputBox));
+ expect(inputBox.size, equals(threeLineInputSize));
+ await tester.enterText(find.byType(TextField), kMoreThanFourLines);
+ await tester.pump();
+ expect(inputBox.size.height, greaterThan(threeLineInputSize.height));
+ expect(inputBox.size.width, threeLineInputSize.width);
+
+ // minLines can't be greater than maxLines.
+ expect(() async {
+ await tester.pumpWidget(textFieldBuilder(minLines: 3, maxLines: 2));
+ }, throwsAssertionError);
+ expect(() async {
+ await tester.pumpWidget(textFieldBuilder(minLines: 3));
+ }, throwsAssertionError);
+
+ // maxLines defaults to 1 and can't be less than minLines
+ expect(() async {
+ await tester.pumpWidget(textFieldBuilder(minLines: 3));
+ }, throwsAssertionError);
+ });
+
+ testWidgets('Multiline text when wrapped in Expanded', (WidgetTester tester) async {
+ Widget expandedTextFieldBuilder({
+ int maxLines = 1,
+ int minLines,
+ bool expands = false,
+ }) {
+ return boilerplate(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ Expanded(
+ child: TextField(
+ key: textFieldKey,
+ style: const TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: maxLines,
+ minLines: minLines,
+ expands: expands,
+ decoration: const InputDecoration(
+ hintText: 'Placeholder',
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ await tester.pumpWidget(expandedTextFieldBuilder());
+
+ RenderBox findBorder() {
+ return tester.renderObject(find.descendant(
+ of: find.byType(InputDecorator),
+ matching: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_BorderContainer'),
+ ));
+ }
+ final RenderBox border = findBorder();
+
+ // Without expanded: true and maxLines: null, the TextField does not expand
+ // to fill its parent when wrapped in an Expanded widget.
+ final Size unexpandedInputSize = border.size;
+
+ // It does expand to fill its parent when expands: true, maxLines: null, and
+ // it's wrapped in an Expanded widget.
+ await tester.pumpWidget(expandedTextFieldBuilder(expands: true, maxLines: null));
+ expect(border.size.height, greaterThan(unexpandedInputSize.height));
+ expect(border.size.width, unexpandedInputSize.width);
+
+ // min/maxLines that is not null and expands: true contradict each other.
+ expect(() async {
+ await tester.pumpWidget(expandedTextFieldBuilder(expands: true, maxLines: 4));
+ }, throwsAssertionError);
+ expect(() async {
+ await tester.pumpWidget(expandedTextFieldBuilder(expands: true, minLines: 1, maxLines: null));
+ }, throwsAssertionError);
+ });
+
+ // Regression test for https://github.com/flutter/flutter/pull/29093
+ testWidgets('Multiline text when wrapped in IntrinsicHeight', (WidgetTester tester) async {
+ final Key intrinsicHeightKey = UniqueKey();
+ Widget intrinsicTextFieldBuilder(bool wrapInIntrinsic) {
+ final TextFormField textField = TextFormField(
+ key: textFieldKey,
+ style: const TextStyle(color: Colors.black, fontSize: 34.0),
+ maxLines: null,
+ decoration: const InputDecoration(
+ counterText: 'I am counter',
+ ),
+ );
+ final Widget widget = wrapInIntrinsic
+ ? IntrinsicHeight(key: intrinsicHeightKey, child: textField)
+ : textField;
+ return boilerplate(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[widget],
+ ),
+ );
+ }
+
+ await tester.pumpWidget(intrinsicTextFieldBuilder(false));
+ expect(find.byKey(intrinsicHeightKey), findsNothing);
+
+ RenderBox findEditableText() => tester.renderObject(find.byType(EditableText));
+ RenderBox editableText = findEditableText();
+ final Size unwrappedEditableTextSize = editableText.size;
+
+ // Wrapping in IntrinsicHeight should not affect the height of the input
+ await tester.pumpWidget(intrinsicTextFieldBuilder(true));
+ editableText = findEditableText();
+ expect(editableText.size.height, unwrappedEditableTextSize.height);
+ expect(editableText.size.width, unwrappedEditableTextSize.width);
+ });
+
+ // Regression test for https://github.com/flutter/flutter/pull/29093
+ testWidgets('errorText empty string', (WidgetTester tester) async {
+ Widget textFormFieldBuilder(String errorText) {
+ return boilerplate(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ TextFormField(
+ key: textFieldKey,
+ maxLength: 3,
+ maxLengthEnforced: false,
+ decoration: InputDecoration(
+ counterText: '',
+ errorText: errorText,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ await tester.pumpWidget(textFormFieldBuilder(null));
+
+ RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey));
+ final RenderBox inputBox = findInputBox();
+ final Size errorNullInputSize = inputBox.size;
+
+ // Setting errorText causes the input's height to increase to accommodate it
+ await tester.pumpWidget(textFormFieldBuilder('im errorText'));
+ expect(inputBox, findInputBox());
+ expect(inputBox.size.height, greaterThan(errorNullInputSize.height));
+ expect(inputBox.size.width, errorNullInputSize.width);
+ final Size errorInputSize = inputBox.size;
+
+ // Setting errorText to an empty string causes the input's height to
+ // increase to accommodate it, even though it's not displayed.
+ // This may or may not be ideal behavior, but it is legacy behavior and
+ // there are visual tests that rely on it (see Github issue referenced at
+ // the top of this test). A counterText of empty string does not affect
+ // input height, however.
+ await tester.pumpWidget(textFormFieldBuilder(''));
+ expect(inputBox, findInputBox());
+ expect(inputBox.size.height, errorInputSize.height);
+ expect(inputBox.size.width, errorNullInputSize.width);
+ });
+
+ testWidgets('Growable TextField when content height exceeds parent', (WidgetTester tester) async {
+ const double height = 200.0;
+ const double padding = 24.0;
+
+ Widget containedTextFieldBuilder({
+ Widget counter,
+ String helperText,
+ String labelText,
+ Widget prefix,
+ }) {
+ return boilerplate(
+ child: Container(
+ height: height,
+ child: TextField(
+ key: textFieldKey,
+ maxLines: null,
+ decoration: InputDecoration(
+ counter: counter,
+ helperText: helperText,
+ labelText: labelText,
+ prefix: prefix,
+ ),
+ ),
+ ),
+ );
+ }
+
+ await tester.pumpWidget(containedTextFieldBuilder());
+ RenderBox findEditableText() => tester.renderObject(find.byType(EditableText));
+
+ final RenderBox inputBox = findEditableText();
+
+ // With no decoration and when overflowing with content, the EditableText
+ // takes up the full height minus the padding, so the input fits perfectly
+ // inside the parent.
+ await tester.enterText(find.byType(TextField), 'a\n' * 11);
+ await tester.pump();
+ expect(findEditableText(), equals(inputBox));
+ expect(inputBox.size.height, height - padding);
+
+ // Adding a counter causes the EditableText to shrink to fit the counter
+ // inside the parent as well.
+ const double counterHeight = 40.0;
+ const double subtextGap = 8.0;
+ const double counterSpace = counterHeight + subtextGap;
+ await tester.pumpWidget(containedTextFieldBuilder(
+ counter: Container(height: counterHeight),
+ ));
+ expect(findEditableText(), equals(inputBox));
+ expect(inputBox.size.height, height - padding - counterSpace);
+
+ // Including helperText causes the EditableText to shrink to fit the text
+ // inside the parent as well.
+ await tester.pumpWidget(containedTextFieldBuilder(
+ helperText: 'I am helperText',
+ ));
+ expect(findEditableText(), equals(inputBox));
+ const double helperTextSpace = 12.0;
+ expect(inputBox.size.height, height - padding - helperTextSpace - subtextGap);
+
+ // When both helperText and counter are present, EditableText shrinks by the
+ // height of the taller of the two in order to fit both within the parent.
+ await tester.pumpWidget(containedTextFieldBuilder(
+ counter: Container(height: counterHeight),
+ helperText: 'I am helperText',
+ ));
+ expect(findEditableText(), equals(inputBox));
+ expect(inputBox.size.height, height - padding - counterSpace);
+
+ // When a label is present, EditableText shrinks to fit it at the top so
+ // that the bottom of the input still lines up perfectly with the parent.
+ await tester.pumpWidget(containedTextFieldBuilder(
+ labelText: 'I am labelText',
+ ));
+ const double labelSpace = 16.0;
+ expect(findEditableText(), equals(inputBox));
+ expect(inputBox.size.height, height - padding - labelSpace);
+
+ // When decoration is present on the top and bottom, EditableText shrinks to
+ // fit both inside the parent independently.
+ await tester.pumpWidget(containedTextFieldBuilder(
+ counter: Container(height: counterHeight),
+ labelText: 'I am labelText',
+ ));
+ expect(findEditableText(), equals(inputBox));
+ expect(inputBox.size.height, height - padding - counterSpace - labelSpace);
+
+ // When a prefix or suffix is present in an input that's full of content,
+ // it is ignored and allowed to expand beyond the top of the input. Other
+ // top and bottom decoration is still respected.
+ await tester.pumpWidget(containedTextFieldBuilder(
+ counter: Container(height: counterHeight),
+ labelText: 'I am labelText',
+ prefix: Container(
+ width: 10,
+ height: 60,
+ ),
+ ));
+ expect(findEditableText(), equals(inputBox));
+ expect(
+ inputBox.size.height,
+ height
+ - padding
+ - labelSpace
+ - counterSpace,
+ );
+ });
testWidgets('Multiline hint text will wrap up to maxLines', (WidgetTester tester) async {
final Key textFieldKey = UniqueKey();
diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart
index 196eb31..f54ba7a 100644
--- a/packages/flutter/test/rendering/editable_test.dart
+++ b/packages/flutter/test/rendering/editable_test.dart
@@ -56,6 +56,7 @@
' │ cursorColor: null\n'
' │ showCursor: ValueNotifier<bool>#00000(false)\n'
' │ maxLines: 1\n'
+ ' │ minLines: null\n'
' │ selectionColor: null\n'
' │ textScaleFactor: 1.0\n'
' │ locale: ja_JP\n'