diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index a6a5df6aba..31be91b94e 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -449,6 +449,7 @@ class _Decoration { this.helperError, this.counter, this.container, + this.alignLabelWithHint, }) : assert(contentPadding != null), assert(isCollapsed != null), assert(floatingLabelHeight != null), @@ -460,6 +461,7 @@ class _Decoration { final double floatingLabelProgress; final InputBorder border; final _InputBorderGap borderGap; + final bool alignLabelWithHint; final Widget icon; final Widget input; final Widget label; @@ -494,7 +496,8 @@ class _Decoration { && typedOther.suffixIcon == suffixIcon && typedOther.helperError == helperError && typedOther.counter == counter - && typedOther.container == container; + && typedOther.container == container + && typedOther.alignLabelWithHint == alignLabelWithHint; } @override @@ -516,6 +519,7 @@ class _Decoration { helperError, counter, container, + alignLabelWithHint, ); } } @@ -839,8 +843,15 @@ class _RenderDecoration extends RenderBox { + contentPadding.right)); boxConstraints = boxConstraints.copyWith(maxWidth: inputWidth); - if (label != null) // The label is not baseline aligned. - label.layout(boxConstraints, parentUsesSize: true); + 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); @@ -1037,8 +1048,13 @@ class _RenderDecoration extends RenderBox { start += contentPadding.left; start -= centerLayout(prefixIcon, start - prefixIcon.size.width); } - if (label != null) - centerLayout(label, start - label.size.width); + if (label != null) { + if (decoration.alignLabelWithHint) { + baselineLayout(label, start - label.size.width); + } else { + centerLayout(label, start - label.size.width); + } + } if (prefix != null) start -= baselineLayout(prefix, start - prefix.size.width); if (input != null) @@ -1061,7 +1077,11 @@ class _RenderDecoration extends RenderBox { start += centerLayout(prefixIcon, start); } if (label != null) - centerLayout(label, start); + if (decoration.alignLabelWithHint) { + baselineLayout(label, start); + } else { + centerLayout(label, start); + } if (prefix != null) start += baselineLayout(prefix, start); if (input != null) @@ -1891,6 +1911,7 @@ class _InputDecoratorState extends State with TickerProviderStat icon: icon, input: widget.child, label: label, + alignLabelWithHint: decoration.alignLabelWithHint, hint: hint, prefix: prefix, suffix: suffix, @@ -1972,6 +1993,7 @@ class InputDecoration { this.border, this.enabled = true, this.semanticCounterText, + this.alignLabelWithHint, }) : assert(enabled != null), assert(!(prefix != null && prefixText != null), 'Declaring both prefix and prefixText is not allowed'), assert(!(suffix != null && suffixText != null), 'Declaring both suffix and suffixText is not allowed'), @@ -2018,7 +2040,8 @@ class InputDecoration { focusedErrorBorder = null, disabledBorder = null, enabledBorder = null, - semanticCounterText = null; + semanticCounterText = null, + alignLabelWithHint = false; /// An icon to show before the input field and outside of the decoration's /// container. @@ -2449,6 +2472,13 @@ class InputDecoration { /// If provided, this replaces the semantic label of the [counterText]. final String semanticCounterText; + /// Typically set to true when the [InputDecorator] contains a multiline + /// [TextField] ([TextField.maxLines] is null or > 1) to override the default + /// behavior of aligning the label with the center of the [TextField]. + /// + /// Defaults to false. + final bool alignLabelWithHint; + /// Creates a copy of this input decoration with the given fields replaced /// by the new values. /// @@ -2488,6 +2518,7 @@ class InputDecoration { InputBorder border, bool enabled, String semanticCounterText, + bool alignLabelWithHint, }) { return InputDecoration( icon: icon ?? this.icon, @@ -2524,6 +2555,7 @@ class InputDecoration { border: border ?? this.border, enabled: enabled ?? this.enabled, semanticCounterText: semanticCounterText ?? this.semanticCounterText, + alignLabelWithHint: alignLabelWithHint ?? this.alignLabelWithHint, ); } @@ -2553,6 +2585,7 @@ class InputDecoration { disabledBorder: disabledBorder ?? theme.disabledBorder, enabledBorder: enabledBorder ?? theme.enabledBorder, border: border ?? theme.border, + alignLabelWithHint: alignLabelWithHint ?? theme.alignLabelWithHint, ); } @@ -2597,7 +2630,8 @@ class InputDecoration { && typedOther.enabledBorder == enabledBorder && typedOther.border == border && typedOther.enabled == enabled - && typedOther.semanticCounterText == semanticCounterText; + && typedOther.semanticCounterText == semanticCounterText + && typedOther.alignLabelWithHint == alignLabelWithHint; } @override @@ -2647,6 +2681,7 @@ class InputDecoration { border, enabled, semanticCounterText, + alignLabelWithHint, ), ); } @@ -2718,6 +2753,8 @@ class InputDecoration { description.add('enabled: false'); if (semanticCounterText != null) description.add('semanticCounterText: $semanticCounterText'); + if (alignLabelWithHint != null) + description.add('alignLabelWithHint: $alignLabelWithHint'); return 'InputDecoration(${description.join(', ')})'; } } @@ -2759,9 +2796,11 @@ class InputDecorationTheme extends Diagnosticable { this.disabledBorder, this.enabledBorder, this.border, + this.alignLabelWithHint = false, }) : assert(isDense != null), assert(isCollapsed != null), - assert(filled != null); + assert(filled != null), + assert(alignLabelWithHint != null); /// The style to use for [InputDecoration.labelText] when the label is /// above (i.e., vertically adjacent to) the input field. @@ -3020,6 +3059,11 @@ class InputDecorationTheme extends Diagnosticable { /// rounded rectangle around the input decorator's container. final InputBorder border; + /// Typically set to true when the [InputDecorator] contains a multiline + /// [TextField] ([TextField.maxLines] is null or > 1) to override the default + /// behavior of aligning the label with the center of the [TextField]. + final bool alignLabelWithHint; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -3040,9 +3084,10 @@ class InputDecorationTheme extends Diagnosticable { properties.add(DiagnosticsProperty('fillColor', fillColor, defaultValue: defaultTheme.fillColor)); properties.add(DiagnosticsProperty('errorBorder', errorBorder, defaultValue: defaultTheme.errorBorder)); properties.add(DiagnosticsProperty('focusedBorder', focusedBorder, defaultValue: defaultTheme.focusedErrorBorder)); - properties.add(DiagnosticsProperty('focusedErrorborder', focusedErrorBorder, defaultValue: defaultTheme.focusedErrorBorder)); + properties.add(DiagnosticsProperty('focusedErrorBorder', focusedErrorBorder, defaultValue: defaultTheme.focusedErrorBorder)); properties.add(DiagnosticsProperty('disabledBorder', disabledBorder, defaultValue: defaultTheme.disabledBorder)); properties.add(DiagnosticsProperty('enabledBorder', enabledBorder, defaultValue: defaultTheme.enabledBorder)); properties.add(DiagnosticsProperty('border', border, defaultValue: defaultTheme.border)); + properties.add(DiagnosticsProperty('alignLabelWithHint', alignLabelWithHint, defaultValue: defaultTheme.alignLabelWithHint)); } } diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index c116e32d87..7caa45d984 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -248,6 +248,56 @@ void main() { expect(tester.getTopLeft(find.text('label')).dy, 20.0); expect(tester.getBottomLeft(find.text('label')).dy, 36.0); expect(getBorderColor(tester), Colors.transparent); + + // alignLabelWithHint: true positions the label at the text baseline, + // aligned with the hint. + await tester.pumpWidget( + buildInputDecorator( + isEmpty: true, + isFocused: false, + decoration: const InputDecoration( + labelText: 'label', + alignLabelWithHint: true, + hintText: 'hint', + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy); + }); + + testWidgets('InputDecorator alignLabelWithHint for multiline TextField', (WidgetTester tester) async { + Widget buildFrame(bool alignLabelWithHint) { + return MaterialApp( + home: Material( + child: Directionality( + textDirection: TextDirection.ltr, + child: TextField( + maxLines: 8, + decoration: InputDecoration( + labelText: 'label', + alignLabelWithHint: alignLabelWithHint, + hintText: 'hint', + ), + ), + ), + ), + ); + } + + // alignLabelWithHint: false centers the label in the TextField + await tester.pumpWidget(buildFrame(false)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, 76.0); + expect(tester.getBottomLeft(find.text('label')).dy, 92.0); + + // alignLabelWithHint: true aligns the label with the hint. + await tester.pumpWidget(buildFrame(true)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('label')).dy, tester.getTopLeft(find.text('hint')).dy); + expect(tester.getBottomLeft(find.text('label')).dy, tester.getBottomLeft(find.text('hint')).dy); }); // Overall height for this InputDecorator is 40.0dps @@ -1552,6 +1602,7 @@ void main() { filled: true, fillColor: Colors.red, border: InputBorder.none, + alignLabelWithHint: true, ) ); @@ -1567,6 +1618,7 @@ void main() { expect(decoration.filled, true); expect(decoration.fillColor, Colors.red); expect(decoration.border, InputBorder.none); + expect(decoration.alignLabelWithHint, true); // InputDecoration (baseDecoration) defines InputDecoration properties decoration = const InputDecoration( @@ -1582,6 +1634,7 @@ void main() { filled: false, fillColor: Colors.blue, border: OutlineInputBorder(), + alignLabelWithHint: false, ).applyDefaults( const InputDecorationTheme( labelStyle: themeStyle, @@ -1597,6 +1650,7 @@ void main() { filled: true, fillColor: Colors.red, border: InputBorder.none, + alignLabelWithHint: true, ), ); @@ -1613,6 +1667,7 @@ void main() { expect(decoration.filled, false); expect(decoration.fillColor, Colors.blue); expect(decoration.border, const OutlineInputBorder()); + expect(decoration.alignLabelWithHint, false); }); testWidgets('InputDecorator OutlineInputBorder fillColor is clipped by border', (WidgetTester tester) async { @@ -2017,4 +2072,51 @@ void main() { expect(underlineInputBorder.hashCode, const UnderlineInputBorder(borderSide: BorderSide(color: Colors.blue)).hashCode); expect(underlineInputBorder.hashCode, isNot(const UnderlineInputBorder().hashCode)); }); + + testWidgets('InputDecorationTheme implements debugFillDescription', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const InputDecorationTheme( + labelStyle: TextStyle(), + helperStyle: TextStyle(), + hintStyle: TextStyle(), + errorMaxLines: 5, + hasFloatingPlaceholder: false, + contentPadding: EdgeInsetsDirectional.only(start: 40.0, top: 12.0, bottom: 12.0), + prefixStyle: TextStyle(), + suffixStyle: TextStyle(), + counterStyle: TextStyle(), + filled: true, + fillColor: Colors.red, + errorBorder: UnderlineInputBorder(), + focusedBorder: UnderlineInputBorder(), + focusedErrorBorder: UnderlineInputBorder(), + disabledBorder: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder(), + border: UnderlineInputBorder(), + alignLabelWithHint: true, + ).debugFillProperties(builder); + final List description = builder.properties + .where((DiagnosticsNode n) => !n.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode n) => n.toString()).toList(); + expect(description, [ + 'labelStyle: TextStyle()', + 'helperStyle: TextStyle()', + 'hintStyle: TextStyle()', + 'errorMaxLines: 5', + 'hasFloatingPlaceholder: false', + 'contentPadding: EdgeInsetsDirectional(40.0, 12.0, 0.0, 12.0)', + 'prefixStyle: TextStyle()', + 'suffixStyle: TextStyle()', + 'counterStyle: TextStyle()', + 'filled: true', + 'fillColor: MaterialColor(primary value: Color(0xfff44336))', + 'errorBorder: UnderlineInputBorder()', + 'focusedBorder: UnderlineInputBorder()', + 'focusedErrorBorder: UnderlineInputBorder()', + 'disabledBorder: UnderlineInputBorder()', + 'enabledBorder: UnderlineInputBorder()', + 'border: UnderlineInputBorder()', + 'alignLabelWithHint: true', + ]); + }); }