Commit 94d0b2ff authored by TL Lee's avatar TL Lee Committed by Will Larche

[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.
parent 903c5f8d
......@@ -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),
......
......@@ -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<InputDecorator> 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<InputDecorator> 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<InputDecorator> 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<InputDecorator> 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<InputDecorator> 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<InputDecorator> 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<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));
......
......@@ -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<FadeTransition>(
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<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 @@ 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));
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment