From 9e9f48dabb8423a6285bddfb21e69f0751060459 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Fri, 8 Mar 2019 12:22:43 -0800 Subject: [PATCH] Fix TextField height issues (#27205) * Create new TextField attribute to control maxLength behaviour * Create test case for maxLinesIncrementalHeight * fix maxLinesIncrementalHeight set method * fix editable_test.dart * Just introducing some proposed API additions, renaming to expands * Pass minLines and expands through to child widgets and validate * minLines can't be null, and expands can't be true when maxLines is 1 * Implement minLines and maxLines height sizing logic * Simplify minLines validation logic * expands parameter proof of concept * min/max mixup bug fix, and tests work with expands false * Test expands=true cases, and fix textPainter.height being out of date * Test all behavior matrix cases * min/max assertion more strict, can't be equal * Tests work that were missing expands serialization * Action sheet tests no longer fail due to rounding error * TextFieldFocus test no longer mysteriously fails * TODOs for making expands nullable. Will depend on how Expanded wrapping works * Expanded growth happens when expanded is true and maxLines is null * Test Expanded wrapper * No more overflow when wrapped in Expanded * Docs improvements * expands can be null * Simplify error cases to support existing behavior * Docs examples and other docs cleanup * Expansion up to perfectly meet the parent size * Fix analyze null error * Fix test after move to nullable expands * minLines defaults to null * expands is now exclusively for expanding to parent height and not growth between min and max * _layout rewritten to handle max height. Need to fix prefix tests and reenable expands * Tests for textfield overflowing parent * layoutLineBox is documented and private * expands works in new _layout * _layout return numbers seem to perfectly match original _layout * inputWidth comment after trying it out and failing tests * Fix analyze errors * WIP prefix/suffix do affect height * Prefix/suffix and icons affect height, tests pass, but I'm still visually verifying identical to original * Tall prefix test that verifies pixel perfect layout * Fix overflowing edge case and test it * Clean up comments, old code, and todos * Changing _expands causes relayout. Wasnt able to figure out how to test though... * Clean up code review comments * Fix misalignment when tall prefix and border, and clean up related test * Simple code review cleanup * Bring back inputWidth to _layout method * Fix rounding errors showing up in mac tests * Fix flake by reordering tests. Without this, the dreaded intrinsicwidth flake is reproducible 50% of the time on my machine. * Fix more rounding error mac tests --- .../lib/src/material/input_decorator.dart | 273 +++++++++++----- .../flutter/lib/src/material/text_field.dart | 23 ++ packages/flutter/lib/src/rendering/box.dart | 4 +- .../flutter/lib/src/rendering/editable.dart | 61 +++- .../lib/src/widgets/editable_text.dart | 98 +++++- .../test/material/input_decorator_test.dart | 107 ++++++- .../test/material/text_field_focus_test.dart | 86 ++--- .../test/material/text_field_test.dart | 297 ++++++++++++++++-- .../flutter/test/rendering/editable_test.dart | 1 + 9 files changed, 777 insertions(+), 173 deletions(-) diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index 580d4568d7f..9c0935e2798 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -552,13 +552,16 @@ class _RenderDecoration extends RenderBox { @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; final Map<_DecorationSlot, RenderBox> slotToChild = <_DecorationSlot, RenderBox>{}; final Map childToSlot = {}; @@ -709,6 +712,16 @@ class _RenderDecoration extends RenderBox { 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 +817,31 @@ class _RenderDecoration extends RenderBox { 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 boxToBaseline = {}; - 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 boxToBaseline = {}; + 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 +851,144 @@ class _RenderDecoration extends RenderBox { + _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. + const double subtextGap = 8.0; + 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 * 2; + final _HelperError helperErrorWidget = decoration.helperError; + final double helperErrorHeight = helperErrorWidget.helperText == null + ? 0 + : helperError.size.height + subtextGap * 2; + 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 (helperErrorWidget.helperText != null) { + subtextHelperBaseline = + containerHeight + subtextGap + boxToBaseline[helperError]; + subtextHelperHeight = helperError.size.height + subtextGap; + } + final double subtextBaseline = math.max( + subtextCounterBaseline, + subtextHelperBaseline, + ); + final double subtextHeight = math.max( + subtextCounterHeight, + subtextHelperHeight, + ); return _RenderDecorationLayout( boxToBaseline: boxToBaseline, @@ -1370,15 +1452,18 @@ class _Decorator extends RenderObjectWidget { @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 +1475,7 @@ class _Decorator extends RenderObjectWidget { textDirection: textDirection, textBaseline: textBaseline, isFocused: isFocused, + expands: expands, ); } @@ -1399,6 +1485,7 @@ class _Decorator extends RenderObjectWidget { ..decoration = decoration ..textDirection = textDirection ..textBaseline = textBaseline + ..expands = expands ..isFocused = isFocused; } } @@ -1461,6 +1548,7 @@ class InputDecorator extends StatefulWidget { this.baseStyle, this.textAlign, this.isFocused = false, + this.expands = false, this.isEmpty = false, this.child, }) : assert(isFocused != null), @@ -1495,6 +1583,19 @@ class InputDecorator extends StatefulWidget { /// 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 +1634,7 @@ class InputDecorator extends StatefulWidget { properties.add(DiagnosticsProperty('decoration', decoration)); properties.add(DiagnosticsProperty('baseStyle', baseStyle, defaultValue: null)); properties.add(DiagnosticsProperty('isFocused', isFocused)); + properties.add(DiagnosticsProperty('expands', expands, defaultValue: false)); properties.add(DiagnosticsProperty('isEmpty', isEmpty)); } } @@ -1928,6 +2030,7 @@ class _InputDecoratorState extends State with TickerProviderStat 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 9b8b141e9d2..639948884cb 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -147,6 +147,8 @@ class TextField extends StatefulWidget { 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 @@ class TextField extends StatefulWidget { 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 @@ class TextField extends StatefulWidget { /// {@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 @@ class TextField extends StatefulWidget { properties.add(DiagnosticsProperty('obscureText', obscureText, defaultValue: false)); properties.add(DiagnosticsProperty('autocorrect', autocorrect, defaultValue: true)); properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add(DiagnosticsProperty('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, defaultValue: null)); @@ -851,6 +871,8 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi 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 @@ class _TextFieldState extends State with AutomaticKeepAliveClientMixi textAlign: widget.textAlign, isFocused: focusNode.hasFocus, isEmpty: controller.value.text.isEmpty, + expands: widget.expands, child: child, ); }, diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index eb4d494c1d1..283d734b9bd 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -1801,9 +1801,9 @@ abstract class RenderBox extends RenderObject { 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 adbbc63f793..27b5f6b9d25 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -144,6 +144,8 @@ class RenderEditable extends RenderBox { ValueNotifier showCursor, bool hasFocus, int maxLines = 1, + int minLines, + bool expands = false, StrutStyle strutStyle, Color selectionColor, double textScaleFactor = 1.0, @@ -165,6 +167,16 @@ class RenderEditable extends RenderBox { }) : 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 @@ class RenderEditable extends RenderBox { _showCursor = showCursor ?? ValueNotifier(false), _hasFocus = hasFocus ?? false, _maxLines = maxLines, + _minLines = minLines, + _expands = expands, _selectionColor = selectionColor, _selection = selection, _offset = offset, @@ -691,6 +705,29 @@ class RenderEditable extends RenderBox { 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; @@ -1150,8 +1187,28 @@ class RenderEditable extends RenderBox { 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; @@ -1613,6 +1670,8 @@ class RenderEditable extends RenderBox { properties.add(DiagnosticsProperty('cursorColor', cursorColor)); properties.add(DiagnosticsProperty>('showCursor', showCursor)); properties.add(IntProperty('maxLines', maxLines)); + properties.add(IntProperty('minLines', minLines)); + properties.add(DiagnosticsProperty('expands', expands, defaultValue: false)); properties.add(DiagnosticsProperty('selectionColor', selectionColor)); properties.add(DoubleProperty('textScaleFactor', textScaleFactor)); properties.add(DiagnosticsProperty('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 1012034c327..d6a0b049ed7 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -278,6 +278,8 @@ class EditableText extends StatefulWidget { this.locale, this.textScaleFactor, this.maxLines = 1, + this.minLines, + this.expands = false, this.autofocus = false, this.selectionColor, this.selectionControls, @@ -310,6 +312,16 @@ class EditableText extends StatefulWidget { 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 @@ class EditableText extends StatefulWidget { /// 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 @@ class EditableText extends StatefulWidget { properties.add(DiagnosticsProperty('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('expands', expands, defaultValue: false)); properties.add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); properties.add(DiagnosticsProperty('keyboardType', keyboardType, defaultValue: null)); } @@ -795,7 +875,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien // 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien : _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 @@ class _Editable extends LeafRenderObjectWidget { this.showCursor, this.hasFocus, this.maxLines, + this.minLines, + this.expands, this.strutStyle, this.selectionColor, this.textScaleFactor, @@ -1433,6 +1517,8 @@ class _Editable extends LeafRenderObjectWidget { final ValueNotifier 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 @@ class _Editable extends LeafRenderObjectWidget { showCursor: showCursor, hasFocus: hasFocus, maxLines: maxLines, + minLines: minLines, + expands: expands, strutStyle: strutStyle, selectionColor: selectionColor, textScaleFactor: textScaleFactor, @@ -1492,6 +1580,8 @@ class _Editable extends LeafRenderObjectWidget { ..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 101a1eb459f..8e8cba89405 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -993,6 +993,100 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 67039eaf693..f7044ee3642 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_test/flutter_test.dart'; 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( + 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(); @@ -89,49 +132,6 @@ void main() { 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( - 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('Focus triggers keep-alive', (WidgetTester tester) async { final FocusNode focusNode = FocusNode(); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index d51e41c6a01..2d22bb70c2e 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -174,6 +174,24 @@ void main() { 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 @@ void main() { 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,278 @@ void main() { 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: [ + 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); + }); + + 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 * 2; + 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 = 28.0; + expect(inputBox.size.height, height - padding - helperTextSpace); + + // 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 1e0e36fb4f7..6bf76b7dab1 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -56,6 +56,7 @@ void main() { ' │ cursorColor: null\n' ' │ showCursor: ValueNotifier#00000(false)\n' ' │ maxLines: 1\n' + ' │ minLines: null\n' ' │ selectionColor: null\n' ' │ textScaleFactor: 1.0\n' ' │ locale: ja_JP\n'