diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index af012b7338..8237a4b9e2 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -242,6 +242,7 @@ class _HelperError extends StatefulWidget { this.helperStyle, this.errorText, this.errorStyle, + this.errorMaxLines, }) : super(key: key); final TextAlign textAlign; @@ -249,6 +250,7 @@ class _HelperError extends StatefulWidget { final TextStyle helperStyle; final String errorText; final TextStyle errorStyle; + final int errorMaxLines; @override _HelperErrorState createState() => new _HelperErrorState(); @@ -343,6 +345,7 @@ class _HelperErrorState extends State<_HelperError> with SingleTickerProviderSta style: widget.errorStyle, textAlign: widget.textAlign, overflow: TextOverflow.ellipsis, + maxLines: widget.errorMaxLines, ), ), ); @@ -444,7 +447,7 @@ class _Decoration { assert(floatingLabelHeight != null), assert(floatingLabelProgress != null); - final EdgeInsets contentPadding; + final EdgeInsetsGeometry contentPadding; final bool isCollapsed; final double floatingLabelHeight; final double floatingLabelProgress; @@ -800,11 +803,21 @@ class _RenderDecoration extends RenderBox { double subtextBaseline = 0.0; double subtextHeight = 0.0; if (helperError != null || counter != null) { + boxConstraints = layoutConstraints.loosen(); aboveBaseline = 0.0; belowBaseline = 0.0; - layoutLineBox(helperError); layoutLineBox(counter); + // The helper or error text can occupy the full width less the space + // occupied by the icon and counter. + boxConstraints = boxConstraints.copyWith( + maxWidth: 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; @@ -1637,39 +1650,44 @@ class _InputDecoratorState extends State with TickerProviderStat helperStyle: _getHelperStyle(themeData), errorText: decoration.errorText, errorStyle: _getErrorStyle(themeData), + errorMaxLines: decoration.errorMaxLines, ); final Widget counter = decoration.counterText == null ? null : new Text( decoration.counterText, style: _getHelperStyle(themeData).merge(decoration.counterStyle), - textAlign: textAlign == TextAlign.end ? TextAlign.start : TextAlign.end, overflow: TextOverflow.ellipsis, ); + // The _Decoration widget and _RenderDecoration assume that contentPadding + // has been resolved to EdgeInsets. + final TextDirection textDirection = Directionality.of(context); + final EdgeInsets decorationContentPadding = decoration.contentPadding?.resolve(textDirection); + EdgeInsets contentPadding; double floatingLabelHeight; if (decoration.isCollapsed) { floatingLabelHeight = 0.0; - contentPadding = decoration.contentPadding ?? EdgeInsets.zero; + contentPadding = decorationContentPadding ?? EdgeInsets.zero; } else if (!decoration.border.isOutline) { // 4.0: the vertical gap between the inline elements and the floating label. floatingLabelHeight = 4.0 + 0.75 * inlineLabelStyle.fontSize; if (decoration.filled == true) { // filled == null same as filled == false - contentPadding = decoration.contentPadding ?? (decorationIsDense + contentPadding = decorationContentPadding ?? (decorationIsDense ? const EdgeInsets.fromLTRB(12.0, 8.0, 12.0, 8.0) : const EdgeInsets.fromLTRB(12.0, 12.0, 12.0, 12.0)); } else { // Not left or right padding for underline borders that aren't filled // is a small concession to backwards compatibility. This eliminates // the most noticeable layout change introduced by #13734. - contentPadding = decoration.contentPadding ?? (decorationIsDense + contentPadding = decorationContentPadding ?? (decorationIsDense ? const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0) : const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 12.0)); } } else { floatingLabelHeight = 0.0; - contentPadding = decoration.contentPadding ?? (decorationIsDense + contentPadding = decorationContentPadding ?? (decorationIsDense ? const EdgeInsets.fromLTRB(12.0, 20.0, 12.0, 12.0) : const EdgeInsets.fromLTRB(12.0, 24.0, 12.0, 16.0)); } @@ -1720,8 +1738,9 @@ class InputDecoration { /// /// Unless specified by [ThemeData.inputDecorationTheme], /// [InputDecorator] defaults [isDense] to true, and [filled] to false, - /// Similarly, the default border is an instance of [UnderlineInputBorder]. - /// If [border] is [InputBorder.none] then no border is drawn. + /// and [maxLines] to 1. The default border is an instance + /// of [UnderlineInputBorder]. If [border] is [InputBorder.none] then + /// no border is drawn. /// /// The [enabled] argument must not be null. const InputDecoration({ @@ -1734,6 +1753,7 @@ class InputDecoration { this.hintStyle, this.errorText, this.errorStyle, + this.errorMaxLines, this.isDense, this.contentPadding, this.prefixIcon, @@ -1770,6 +1790,7 @@ class InputDecoration { helperStyle = null, errorText = null, errorStyle = null, + errorMaxLines = null, isDense = false, contentPadding = EdgeInsets.zero, isCollapsed = true, @@ -1861,6 +1882,16 @@ class InputDecoration { /// input field and the current [Theme]. final TextStyle errorStyle; + + /// The maximum number of lines the [errorText] can occupy. + /// + /// Defaults to null, which means that the [errorText] will be limited + /// to a single line with [TextOverflow.ellipsis]. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the error. + final int errorMaxLines; + /// Whether the input [child] is part of a dense form (i.e., uses less vertical /// space). /// @@ -1877,7 +1908,7 @@ class InputDecoration { /// By default the `contentPadding` reflects [isDense] and the type of the /// [border]. If [isCollapsed] is true then `contentPadding` is /// [EdgeInsets.zero]. - final EdgeInsets contentPadding; + final EdgeInsetsGeometry contentPadding; /// Whether the decoration is the same size as the input field. /// @@ -2024,8 +2055,9 @@ class InputDecoration { TextStyle hintStyle, String errorText, TextStyle errorStyle, + int errorMaxLines, bool isDense, - EdgeInsets contentPadding, + EdgeInsetsGeometry contentPadding, Widget prefixIcon, String prefixText, TextStyle prefixStyle, @@ -2049,6 +2081,7 @@ class InputDecoration { hintStyle: hintStyle ?? this.hintStyle, errorText: errorText ?? this.errorText, errorStyle: errorStyle ?? this.errorStyle, + errorMaxLines: errorMaxLines ?? this.errorMaxLines, isDense: isDense ?? this.isDense, contentPadding: contentPadding ?? this.contentPadding, prefixIcon: prefixIcon ?? this.prefixIcon, @@ -2077,6 +2110,7 @@ class InputDecoration { helperStyle: helperStyle ?? theme.helperStyle, hintStyle: hintStyle ?? theme.hintStyle, errorStyle: errorStyle ?? theme.errorStyle, + errorMaxLines: errorMaxLines ?? theme.errorMaxLines, isDense: isDense ?? theme.isDense, contentPadding: contentPadding ?? theme.contentPadding, prefixStyle: prefixStyle ?? theme.prefixStyle, @@ -2104,6 +2138,7 @@ class InputDecoration { && typedOther.hintStyle == hintStyle && typedOther.errorText == errorText && typedOther.errorStyle == errorStyle + && typedOther.errorMaxLines == errorMaxLines && typedOther.isDense == isDense && typedOther.contentPadding == contentPadding && typedOther.isCollapsed == isCollapsed @@ -2129,11 +2164,12 @@ class InputDecoration { labelStyle, helperText, helperStyle, + hintText, hashValues( // Over 20 fields... - hintText, hintStyle, errorText, errorStyle, + errorMaxLines, isDense, contentPadding, isCollapsed, @@ -2166,6 +2202,10 @@ class InputDecoration { description.add('hintText: "$hintText"'); if (errorText != null) description.add('errorText: "$errorText"'); + if (errorStyle != null) + description.add('errorStyle: "$errorStyle"'); + if (errorMaxLines != null) + description.add('errorMaxLines: "$errorMaxLines"'); if (isDense ?? false) description.add('isDense: $isDense'); if (contentPadding != null) @@ -2221,6 +2261,7 @@ class InputDecorationTheme extends Diagnosticable { this.helperStyle, this.hintStyle, this.errorStyle, + this.errorMaxLines, this.isDense: false, this.contentPadding, this.isCollapsed: false, @@ -2264,6 +2305,15 @@ class InputDecorationTheme extends Diagnosticable { /// input field and the current [Theme]. final TextStyle errorStyle; + /// The maximum number of lines the [errorText] can occupy. + /// + /// Defaults to null, which means that the [errorText] will be limited + /// to a single line with [TextOverflow.ellipsis]. + /// + /// This value is passed along to the [Text.maxLines] attribute + /// of the [Text] widget used to display the error. + final int errorMaxLines; + /// Whether the input decorator's child is part of a dense form (i.e., uses /// less vertical space). /// @@ -2282,7 +2332,7 @@ class InputDecorationTheme extends Diagnosticable { /// By default the `contentPadding` reflects [isDense] and the type of the /// [border]. If [isCollapsed] is true then `contentPadding` is /// [EdgeInsets.zero]. - final EdgeInsets contentPadding; + final EdgeInsetsGeometry contentPadding; /// Whether the decoration is the same size as the input field. /// @@ -2355,6 +2405,7 @@ class InputDecorationTheme extends Diagnosticable { properties.add(new DiagnosticsProperty('helperStyle', helperStyle, defaultValue: defaultTheme.helperStyle)); properties.add(new DiagnosticsProperty('hintStyle', hintStyle, defaultValue: defaultTheme.hintStyle)); properties.add(new DiagnosticsProperty('errorStyle', errorStyle, defaultValue: defaultTheme.errorStyle)); + properties.add(new DiagnosticsProperty('errorMaxLines', errorMaxLines, defaultValue: defaultTheme.errorMaxLines)); properties.add(new DiagnosticsProperty('isDense', isDense, defaultValue: defaultTheme.isDense)); properties.add(new DiagnosticsProperty('contentPadding', contentPadding, defaultValue: defaultTheme.contentPadding)); properties.add(new DiagnosticsProperty('isCollapsed', isCollapsed, defaultValue: defaultTheme.isCollapsed)); diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index a3861c03f1..ae00bf20fc 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -596,6 +596,82 @@ void main() { expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 56.0)); }); + testWidgets('InputDecoration errorMaxLines', (WidgetTester tester) async { + const String kError1 = 'e0'; + const String kError2 = 'e0\ne1'; + const String kError3 = 'e0\ne1\ne2'; + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError3, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + // Overall height for this InputDecorator is 100dps: + // + // 12 - top padding + // 12 - floating label (ahem font size 16dps * 0.75 = 12) + // 4 - floating label / input text gap + // 16 - input text (ahem font size 16dps) + // 12 - bottom padding + // 8 - below the border padding + // 36 - error text (3 lines, ahem font size 12dps) + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 100.0)); + expect(tester.getTopLeft(find.text(kError3)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError3)), const Offset(12.0, 100.0)); + + // Overall height for this InputDecorator is 12 less than the first + // one, 88dps, because errorText only occupies two lines. + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError2, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 88.0)); + expect(tester.getTopLeft(find.text(kError2)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError2)), const Offset(12.0, 88.0)); + + // Overall height for this InputDecorator is 24 less than the first + // one, 88dps, because errorText only occupies one line. + + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + // isFocused: false (default) + decoration: const InputDecoration( + labelText: 'label', + helperText: 'helper', + errorText: kError1, + errorMaxLines: 3, + filled: true, + ), + ), + ); + + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 76.0)); + expect(tester.getTopLeft(find.text(kError1)), const Offset(12.0, 64.0)); + expect(tester.getBottomLeft(find.text(kError1)), const Offset(12.0, 76.0)); + }); + testWidgets('InputDecorator prefix/suffix', (WidgetTester tester) async { await tester.pumpWidget( buildInputDecorator( @@ -760,6 +836,46 @@ void main() { expect(tester.getTopRight(find.text('text')).dx, lessThanOrEqualTo(tester.getTopLeft(find.text('p')).dx)); }); + testWidgets('InputDecorator contentPadding RTL layout', (WidgetTester tester) async { + // LTR: content left edge is contentPadding.start: 40.0 + await tester.pumpWidget( + buildInputDecorator( + // isEmpty: false (default) + // isFocused: false (default) + textDirection: TextDirection.ltr, + decoration: const InputDecoration( + contentPadding: const EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + labelText: 'label', + hintText: 'hint', + filled: true, + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('text')).dx, 40.0); + expect(tester.getTopLeft(find.text('label')).dx, 40.0); + expect(tester.getTopLeft(find.text('hint')).dx, 40.0); + + // RTL: content right edge is 800 - contentPadding.start: 760.0. + await tester.pumpWidget( + buildInputDecorator( + // isEmpty: false (default) + isFocused: true, // label is floating, still adjusted for contentPadding + textDirection: TextDirection.rtl, + decoration: const InputDecoration( + contentPadding: const EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + labelText: 'label', + hintText: 'hint', + filled: true, + ), + ), + ); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopRight(find.text('text')).dx, 760.0); + expect(tester.getTopRight(find.text('label')).dx, 760.0); + expect(tester.getTopRight(find.text('hint')).dx, 760.0); + }); + testWidgets('InputDecorator prefix/suffix dense layout', (WidgetTester tester) async { await tester.pumpWidget( buildInputDecorator( @@ -1194,6 +1310,7 @@ void main() { helperStyle: themeStyle, hintStyle: themeStyle, errorStyle: themeStyle, + errorMaxLines: 4, isDense: true, contentPadding: const EdgeInsets.all(1.0), prefixStyle: themeStyle, @@ -1209,6 +1326,7 @@ void main() { expect(decoration.helperStyle, decorationStyle); expect(decoration.hintStyle, decorationStyle); expect(decoration.errorStyle, decorationStyle); + expect(decoration.errorMaxLines, 4); expect(decoration.isDense, false); expect(decoration.contentPadding, const EdgeInsets.all(4.0)); expect(decoration.prefixStyle, decorationStyle);