From 94d0b2ffc252d6732fc89dafcddcd79dc9f25c37 Mon Sep 17 00:00:00 2001 From: TL Lee Date: Thu, 8 Nov 2018 22:00:50 -0500 Subject: [PATCH] [Material] Add API for an unpainted input border (#21289) * Introduce a rounded InputBorder with no paint * Update documentation for API * Change name of Widget to NoStrokeInputBorder and updated documentation * [FilledInputBorder] PR feedback. * [FilledInputBorder] typo correction. * [FilledInputBorder] Removing news. * [FilledInputBorder] PR feedback. * [FilledInputBorder] Removing use of borderSide. * [FilledInputBorder] Adding tests for hashcode. * [FilledInputBorder] Comments. * [TextFields] Input decoration feature parity. * [TextFields] Making floating of placeholder optional. * [TextFields] Cleanup * [TextFields] Removing unused isAnimated. * [TextFields] Cleanup. * [TextFields] Correcting border. * [TextFields] Correcting comment. * [TextFields] Comment. * [TextFields] Corrections for tests. * [TextFields] Cleanup. * [TextFields] Cleanup. * [TextFields] Tests. * [TextFields] Cleanup. * [TextFields] Cleanup. * [TextFields] Formatting. * [TextFields] PR feedback. * [TextFields] PR feedback. * [TextFields] PR feedback. --- .../lib/src/material/input_border.dart | 21 +++- .../lib/src/material/input_decorator.dart | 77 +++++++++--- .../test/material/input_decorator_test.dart | 118 ++++++++++++++++-- 3 files changed, 183 insertions(+), 33 deletions(-) diff --git a/packages/flutter/lib/src/material/input_border.dart b/packages/flutter/lib/src/material/input_border.dart index 6709d16851..8fa7ee4c26 100644 --- a/packages/flutter/lib/src/material/input_border.dart +++ b/packages/flutter/lib/src/material/input_border.dart @@ -139,7 +139,7 @@ class UnderlineInputBorder extends InputBorder { /// and right corners have a circular radius of 4.0. The [borderRadius] /// parameter must not be null. const UnderlineInputBorder({ - BorderSide borderSide = BorderSide.none, + BorderSide borderSide = const BorderSide(), this.borderRadius = const BorderRadius.only( topLeft: Radius.circular(4.0), topRight: Radius.circular(4.0), @@ -256,17 +256,26 @@ class UnderlineInputBorder extends InputBorder { class OutlineInputBorder extends InputBorder { /// Creates a rounded rectangle outline border for an [InputDecorator]. /// - /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be - /// null). Applications typically do not specify a [borderSide] parameter - /// because the input decorator substitutes its own, using [copyWith], based - /// on the current theme and [InputDecorator.isFocused]. + /// If the [borderSide] parameter is [BorderSide.none], it will not draw a + /// border. However, it will still define a shape (which you can see if + /// [InputDecoration.filled] is true). + /// + /// If an application does not specify a [borderSide] parameter of + /// value [BorderSide.none], the input decorator substitutes its own, using + /// [copyWith], based on the current theme and [InputDecorator.isFocused]. /// /// The [borderRadius] parameter defaults to a value where all four /// corners have a circular radius of 4.0. The [borderRadius] parameter /// must not be null and the corner radii must be circular, i.e. their /// [Radius.x] and [Radius.y] values must be the same. + /// + /// See also: + /// * [InputDecoration.hasFloatingPlaceholder], which should be set to false + /// when the [borderSide] is [BorderSide.none]. If let as true, the label + /// will extend beyond the container as if the border were still being + /// drawn. const OutlineInputBorder({ - BorderSide borderSide = BorderSide.none, + BorderSide borderSide = const BorderSide(), this.borderRadius = const BorderRadius.all(Radius.circular(4.0)), this.gapPadding = 4.0, }) : assert(borderRadius != null), diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index 3bebf6d73d..66ad01f4e8 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -1488,7 +1488,9 @@ class InputDecorator extends StatefulWidget { /// Typically an [EditableText], [DropdownButton], or [InkWell]. final Widget child; - bool get _labelIsFloating => !isEmpty || isFocused; + /// Whether the label needs to get out of the way of the input, either by + /// floating or disappearing. + bool get _labelShouldWithdraw => !isEmpty || isFocused; @override _InputDecoratorState createState() => _InputDecoratorState(); @@ -1526,7 +1528,7 @@ class _InputDecoratorState extends State with TickerProviderStat _floatingLabelController = AnimationController( duration: _kTransitionDuration, vsync: this, - value: widget._labelIsFloating ? 1.0 : 0.0, + value: (widget.decoration.hasFloatingPlaceholder && widget._labelShouldWithdraw) ? 1.0 : 0.0, ); _floatingLabelController.addListener(_handleChange); @@ -1573,9 +1575,10 @@ class _InputDecoratorState extends State with TickerProviderStat if (widget.decoration != old.decoration) _effectiveDecoration = null; - if (widget._labelIsFloating != old._labelIsFloating) { - if (widget._labelIsFloating) + if (widget._labelShouldWithdraw != old._labelShouldWithdraw && widget.decoration.hasFloatingPlaceholder) { + if (widget._labelShouldWithdraw) { _floatingLabelController.forward(); + } else _floatingLabelController.reverse(); } @@ -1641,7 +1644,11 @@ class _InputDecoratorState extends State with TickerProviderStat // True if the label will be shown and the hint will not. // If we're not focused, there's no value, and labelText was provided, // then the label appears where the hint would. - bool get _hasInlineLabel => !isFocused && isEmpty && decoration.labelText != null; + bool get _hasInlineLabel => !widget._labelShouldWithdraw && decoration.labelText != null; + + // If the label is a floating placeholder, it's always shown. + bool get _shouldShowLabel => _hasInlineLabel || decoration.hasFloatingPlaceholder; + // The base style for the inline label or hint when they're displayed "inline", // i.e. when they appear in place of the empty text field. @@ -1671,6 +1678,10 @@ class _InputDecoratorState extends State with TickerProviderStat } InputBorder _getDefaultBorder(ThemeData themeData) { + if (decoration.border?.borderSide == BorderSide.none) { + return decoration.border; + } + Color borderColor; if (decoration.enabled) { borderColor = decoration.errorText == null @@ -1731,23 +1742,28 @@ class _InputDecoratorState extends State with TickerProviderStat final TextStyle inlineLabelStyle = inlineStyle.merge(decoration.labelStyle); final Widget label = decoration.labelText == null ? null : _Shaker( animation: _shakingLabelController.view, - child: AnimatedDefaultTextStyle( + child: AnimatedOpacity( duration: _kTransitionDuration, curve: _kTransitionCurve, - style: widget._labelIsFloating - ? _getFloatingLabelStyle(themeData) - : inlineLabelStyle, - child: Text( - decoration.labelText, - overflow: TextOverflow.ellipsis, - textAlign: textAlign, + opacity: _shouldShowLabel ? 1.0 : 0.0, + child: AnimatedDefaultTextStyle( + duration:_kTransitionDuration, + curve: _kTransitionCurve, + style: widget._labelShouldWithdraw + ? _getFloatingLabelStyle(themeData) + : inlineLabelStyle, + child: Text( + decoration.labelText, + overflow: TextOverflow.ellipsis, + textAlign: textAlign, + ), ), ), ); final Widget prefix = decoration.prefix == null && decoration.prefixText == null ? null : _AffixText( - labelIsFloating: widget._labelIsFloating, + labelIsFloating: widget._labelShouldWithdraw, text: decoration.prefixText, style: decoration.prefixStyle ?? hintStyle, child: decoration.prefix, @@ -1755,7 +1771,7 @@ class _InputDecoratorState extends State with TickerProviderStat final Widget suffix = decoration.suffix == null && decoration.suffixText == null ? null : _AffixText( - labelIsFloating: widget._labelIsFloating, + labelIsFloating: widget._labelShouldWithdraw, text: decoration.suffixText, style: decoration.suffixStyle ?? hintStyle, child: decoration.suffix, @@ -1931,6 +1947,7 @@ class InputDecoration { this.errorText, this.errorStyle, this.errorMaxLines, + this.hasFloatingPlaceholder = true, this.isDense, this.contentPadding, this.prefixIcon, @@ -1965,6 +1982,7 @@ class InputDecoration { /// Sets the [isCollapsed] property to true. const InputDecoration.collapsed({ @required this.hintText, + this.hasFloatingPlaceholder = true, this.hintStyle, this.filled = false, this.fillColor, @@ -2088,6 +2106,16 @@ class InputDecoration { /// of the [Text] widget used to display the error. final int errorMaxLines; + /// Whether the label floats on focus. + /// + /// If this is false, the placeholder disappears when the input has focus or + /// inputted text. + /// If this is true, the placeholder will rise to the top of the input when + /// the input has focus or inputted text. + /// + /// Defaults to true. + final bool hasFloatingPlaceholder; + /// Whether the input [child] is part of a dense form (i.e., uses less vertical /// space). /// @@ -2424,6 +2452,7 @@ class InputDecoration { String errorText, TextStyle errorStyle, int errorMaxLines, + bool hasFloatingPlaceholder, bool isDense, EdgeInsetsGeometry contentPadding, Widget prefixIcon, @@ -2458,6 +2487,7 @@ class InputDecoration { errorText: errorText ?? this.errorText, errorStyle: errorStyle ?? this.errorStyle, errorMaxLines: errorMaxLines ?? this.errorMaxLines, + hasFloatingPlaceholder: hasFloatingPlaceholder ?? this.hasFloatingPlaceholder, isDense: isDense ?? this.isDense, contentPadding: contentPadding ?? this.contentPadding, prefixIcon: prefixIcon ?? this.prefixIcon, @@ -2495,6 +2525,7 @@ class InputDecoration { hintStyle: hintStyle ?? theme.hintStyle, errorStyle: errorStyle ?? theme.errorStyle, errorMaxLines: errorMaxLines ?? theme.errorMaxLines, + hasFloatingPlaceholder: hasFloatingPlaceholder ?? theme.hasFloatingPlaceholder, isDense: isDense ?? theme.isDense, contentPadding: contentPadding ?? theme.contentPadding, prefixStyle: prefixStyle ?? theme.prefixStyle, @@ -2528,6 +2559,7 @@ class InputDecoration { && typedOther.errorText == errorText && typedOther.errorStyle == errorStyle && typedOther.errorMaxLines == errorMaxLines + && typedOther.hasFloatingPlaceholder == hasFloatingPlaceholder && typedOther.isDense == isDense && typedOther.contentPadding == contentPadding && typedOther.isCollapsed == isCollapsed @@ -2568,6 +2600,7 @@ class InputDecoration { errorText, errorStyle, errorMaxLines, + hasFloatingPlaceholder, isDense, hashValues( contentPadding, @@ -2619,6 +2652,8 @@ class InputDecoration { description.add('errorStyle: "$errorStyle"'); if (errorMaxLines != null) description.add('errorMaxLines: "$errorMaxLines"'); + if (hasFloatingPlaceholder == false) + description.add('hasFloatingPlaceholder: false'); if (isDense ?? false) description.add('isDense: $isDense'); if (contentPadding != null) @@ -2691,6 +2726,7 @@ class InputDecorationTheme extends Diagnosticable { this.hintStyle, this.errorStyle, this.errorMaxLines, + this.hasFloatingPlaceholder = true, this.isDense = false, this.contentPadding, this.isCollapsed = false, @@ -2747,6 +2783,16 @@ class InputDecorationTheme extends Diagnosticable { /// of the [Text] widget used to display the error. final int errorMaxLines; + /// Whether the placeholder text floats to become a label on focus. + /// + /// If this is false, the placeholder disappears when the input has focus or + /// inputted text. + /// If this is true, the placeholder will rise to the top of the input when + /// the input has focus or inputted text. + /// + /// Defaults to true. + final bool hasFloatingPlaceholder; + /// Whether the input decorator's child is part of a dense form (i.e., uses /// less vertical space). /// @@ -2965,6 +3011,7 @@ class InputDecorationTheme extends Diagnosticable { properties.add(DiagnosticsProperty('hintStyle', hintStyle, defaultValue: defaultTheme.hintStyle)); properties.add(DiagnosticsProperty('errorStyle', errorStyle, defaultValue: defaultTheme.errorStyle)); properties.add(DiagnosticsProperty('errorMaxLines', errorMaxLines, defaultValue: defaultTheme.errorMaxLines)); + properties.add(DiagnosticsProperty('hasFloatingPlaceholder', hasFloatingPlaceholder, defaultValue: defaultTheme.hasFloatingPlaceholder)); properties.add(DiagnosticsProperty('isDense', isDense, defaultValue: defaultTheme.isDense)); properties.add(DiagnosticsProperty('contentPadding', contentPadding, defaultValue: defaultTheme.contentPadding)); properties.add(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 8efce8295b..1c904dd4e4 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -81,10 +81,10 @@ double getBorderWeight(WidgetTester tester) => getBorderSide(tester)?.width; Color getBorderColor(WidgetTester tester) => getBorderSide(tester)?.color; -double getHintOpacity(WidgetTester tester) { +double getOpacity(WidgetTester tester, String textValue) { final FadeTransition opacityWidget = tester.widget( find.ancestor( - of: find.text('hint'), + of: find.text(textValue), matching: find.byType(FadeTransition), ).first ); @@ -304,7 +304,7 @@ void main() { expect(tester.getBottomLeft(find.text('text')).dy, 44.0); expect(tester.getTopLeft(find.text('label')).dy, 20.0); expect(tester.getBottomLeft(find.text('label')).dy, 36.0); - expect(getHintOpacity(tester), 0.0); + expect(getOpacity(tester, 'hint'), 0.0); expect(getBorderBottom(tester), 56.0); expect(getBorderWeight(tester), 1.0); @@ -324,10 +324,10 @@ void main() { // The animation's duration is 200ms. { await tester.pump(const Duration(milliseconds: 50)); - final double hintOpacity50ms = getHintOpacity(tester); + final double hintOpacity50ms = getOpacity(tester, 'hint'); expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); await tester.pump(const Duration(milliseconds: 50)); - final double hintOpacity100ms = getHintOpacity(tester); + final double hintOpacity100ms = getOpacity(tester, 'hint'); expect(hintOpacity100ms, inExclusiveRange(hintOpacity50ms, 1.0)); } @@ -339,7 +339,7 @@ void main() { expect(tester.getBottomLeft(find.text('label')).dy, 24.0); expect(tester.getTopLeft(find.text('hint')).dy, 28.0); expect(tester.getBottomLeft(find.text('hint')).dy, 44.0); - expect(getHintOpacity(tester), 1.0); + expect(getOpacity(tester, 'hint'), 1.0); expect(getBorderBottom(tester), 56.0); expect(getBorderWeight(tester), 2.0); @@ -358,10 +358,10 @@ void main() { // The animation's duration is 200ms. { await tester.pump(const Duration(milliseconds: 50)); - final double hintOpacity50ms = getHintOpacity(tester); + final double hintOpacity50ms = getOpacity(tester, 'hint'); expect(hintOpacity50ms, inExclusiveRange(0.0, 1.0)); await tester.pump(const Duration(milliseconds: 50)); - final double hintOpacity100ms = getHintOpacity(tester); + final double hintOpacity100ms = getOpacity(tester, 'hint'); expect(hintOpacity100ms, inExclusiveRange(0.0, hintOpacity50ms)); } @@ -373,7 +373,7 @@ void main() { expect(tester.getBottomLeft(find.text('label')).dy, 24.0); expect(tester.getTopLeft(find.text('hint')).dy, 28.0); expect(tester.getBottomLeft(find.text('hint')).dy, 44.0); - expect(getHintOpacity(tester), 0.0); + expect(getOpacity(tester, 'hint'), 0.0); expect(getBorderBottom(tester), 56.0); expect(getBorderWeight(tester), 2.0); }); @@ -413,7 +413,7 @@ void main() { expect(tester.getBottomLeft(find.text('text')).dy, 40.0); expect(tester.getTopLeft(find.text('label')).dy, 16.0); expect(tester.getBottomLeft(find.text('label')).dy, 32.0); - expect(getHintOpacity(tester), 0.0); + expect(getOpacity(tester, 'hint'), 0.0); expect(getBorderBottom(tester), 48.0); expect(getBorderWeight(tester), 1.0); @@ -435,7 +435,7 @@ void main() { expect(tester.getBottomLeft(find.text('text')).dy, 40.0); expect(tester.getTopLeft(find.text('label')).dy, 8.0); expect(tester.getBottomLeft(find.text('label')).dy, 20.0); - expect(getHintOpacity(tester), 1.0); + expect(getOpacity(tester, 'hint'), 1.0); expect(getBorderBottom(tester), 48.0); expect(getBorderWeight(tester), 2.0); }); @@ -1167,7 +1167,7 @@ void main() { expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 16.0)); expect(tester.getSize(find.text('text')).height, 16.0); expect(tester.getTopLeft(find.text('text')).dy, 0.0); - expect(getHintOpacity(tester), 0.0); + expect(getOpacity(tester, 'hint'), 0.0); expect(getBorderWeight(tester), 0.0); // The hint should appear @@ -1272,6 +1272,58 @@ void main() { expect(tester.getTopRight(find.text('counter')), const Offset(788.0, 64.0)); }); + 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', + ), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 20 - top padding + // 16 - label (ahem font size 16dps) + // 20 - bottom padding + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 0.0); + }); + + testWidgets('InputDecoration outline shape with no border and no floating placeholder not empty', (WidgetTester tester) async { + await tester.pumpWidget( + buildInputDecorator( + // isEmpty: false (default) + // isFocused: false (default) + decoration: const InputDecoration( + border: OutlineInputBorder(borderSide: BorderSide.none), + hasFloatingPlaceholder: false, + labelText: 'label', + ), + ), + ); + + // Overall height for this InputDecorator is 56dps. Layout is: + // 20 - top padding + // 16 - label (ahem font size 16dps) + // 20 - bottom padding + // expect(tester.widget(find.text('prefix')).style.color, prefixStyle.color); + expect(tester.getSize(find.byType(InputDecorator)), const Size(800.0, 56.0)); + expect(tester.getTopLeft(find.text('label')).dy, 20.0); + expect(tester.getBottomLeft(find.text('label')).dy, 36.0); + expect(getBorderBottom(tester), 56.0); + expect(getBorderWeight(tester), 0.0); + + // The label should not be seen. + expect(getOpacity(tester, 'label'), 0.0); + }); + testWidgets('InputDecorationTheme outline border', (WidgetTester tester) async { await tester.pumpWidget( buildInputDecorator( @@ -1878,4 +1930,46 @@ void main() { await tester.pumpAndSettle(); // border changes are animated expect(getBorder(tester), disabledBorder); }); + + test('InputBorder equality', () { + // OutlineInputBorder's equality is defined by the borderRadius, borderSide, & gapPadding + const OutlineInputBorder outlineInputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ); + expect(outlineInputBorder, const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.all(Radius.circular(9.0)), + gapPadding: 32.0, + )); + expect(outlineInputBorder, isNot(const OutlineInputBorder())); + + // UnderlineInputBorder's equality is defined only by the borderSide + const UnderlineInputBorder underlineInputBorder = UnderlineInputBorder(borderSide: BorderSide(color: Colors.blue)); + expect(underlineInputBorder, const UnderlineInputBorder(borderSide: BorderSide(color: Colors.blue))); + expect(underlineInputBorder, isNot(const UnderlineInputBorder())); + }); + + + test('InputBorder hashCodes', () { + // OutlineInputBorder's hashCode is defined by the borderRadius, borderSide, & gapPadding + const OutlineInputBorder outlineInputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + borderSide: BorderSide(color: Colors.blue), + gapPadding: 32.0, + ); + expect(outlineInputBorder.hashCode, const OutlineInputBorder( + borderSide: BorderSide(color: Colors.blue), + borderRadius: BorderRadius.all(Radius.circular(9.0)), + gapPadding: 32.0, + ).hashCode); + expect(outlineInputBorder.hashCode, isNot(const OutlineInputBorder().hashCode)); + + // UnderlineInputBorder's hashCode is defined only by the borderSide + const UnderlineInputBorder underlineInputBorder = UnderlineInputBorder(borderSide: BorderSide(color: Colors.blue)); + expect(underlineInputBorder.hashCode, const UnderlineInputBorder(borderSide: BorderSide(color: Colors.blue)).hashCode); + expect(underlineInputBorder.hashCode, isNot(const UnderlineInputBorder().hashCode)); + }); + }