[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.
diff --git a/packages/flutter/lib/src/material/input_border.dart b/packages/flutter/lib/src/material/input_border.dart
index 6709d16..8fa7ee4 100644
--- a/packages/flutter/lib/src/material/input_border.dart
+++ b/packages/flutter/lib/src/material/input_border.dart
@@ -139,7 +139,7 @@
   /// 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 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 3bebf6d..66ad01f 100644
--- a/packages/flutter/lib/src/material/input_decorator.dart
+++ b/packages/flutter/lib/src/material/input_decorator.dart
@@ -1488,7 +1488,9 @@
   /// 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 @@
     _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 @@
     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 @@
   // 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 @@
   }
 
   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 @@
     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 @@
 
     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 @@
     this.errorText,
     this.errorStyle,
     this.errorMaxLines,
+    this.hasFloatingPlaceholder = true,
     this.isDense,
     this.contentPadding,
     this.prefixIcon,
@@ -1965,6 +1982,7 @@
   /// 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 @@
   /// 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 @@
     String errorText,
     TextStyle errorStyle,
     int errorMaxLines,
+    bool hasFloatingPlaceholder,
     bool isDense,
     EdgeInsetsGeometry contentPadding,
     Widget prefixIcon,
@@ -2458,6 +2487,7 @@
       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 @@
       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 @@
         && 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 @@
       errorText,
       errorStyle,
       errorMaxLines,
+      hasFloatingPlaceholder,
       isDense,
       hashValues(
         contentPadding,
@@ -2619,6 +2652,8 @@
       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 @@
     this.hintStyle,
     this.errorStyle,
     this.errorMaxLines,
+    this.hasFloatingPlaceholder = true,
     this.isDense = false,
     this.contentPadding,
     this.isCollapsed = false,
@@ -2747,6 +2783,16 @@
   /// 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 @@
     properties.add(DiagnosticsProperty<TextStyle>('hintStyle', hintStyle, defaultValue: defaultTheme.hintStyle));
     properties.add(DiagnosticsProperty<TextStyle>('errorStyle', errorStyle, defaultValue: defaultTheme.errorStyle));
     properties.add(DiagnosticsProperty<int>('errorMaxLines', errorMaxLines, defaultValue: defaultTheme.errorMaxLines));
+    properties.add(DiagnosticsProperty<bool>('hasFloatingPlaceholder', hasFloatingPlaceholder, defaultValue: defaultTheme.hasFloatingPlaceholder));
     properties.add(DiagnosticsProperty<bool>('isDense', isDense, defaultValue: defaultTheme.isDense));
     properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('contentPadding', contentPadding, defaultValue: defaultTheme.contentPadding));
     properties.add(DiagnosticsProperty<bool>('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 8efce82..1c904dd 100644
--- a/packages/flutter/test/material/input_decorator_test.dart
+++ b/packages/flutter/test/material/input_decorator_test.dart
@@ -81,10 +81,10 @@
 
 Color getBorderColor(WidgetTester tester) => getBorderSide(tester)?.color;
 
-double getHintOpacity(WidgetTester tester) {
+double getOpacity(WidgetTester tester, String textValue) {
   final FadeTransition opacityWidget = tester.widget<FadeTransition>(
     find.ancestor(
-      of: find.text('hint'),
+      of: find.text(textValue),
       matching: find.byType(FadeTransition),
     ).first
   );
@@ -304,7 +304,7 @@
     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 @@
     // 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 @@
     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 @@
     // 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 @@
     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 @@
     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 @@
     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 @@
     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 @@
     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<Text>(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 @@
     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));
+  });
+
 }