Adds prefix and suffix support to TextField, per Material Design spec. (#10675)

* Prefix and Suffix support for TextFields

* Adding Tests

* Removing spurious newline.

* Fixing a small problem with the test

* Review Changes
This commit is contained in:
gspencergoog 2017-06-13 19:17:04 -07:00 committed by Ian Hickson
parent befe019896
commit 9f344b695d
3 changed files with 348 additions and 19 deletions

View file

@ -129,6 +129,7 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
icon: const Icon(Icons.phone),
hintText: 'Where can we reach you?',
labelText: 'Phone Number *',
prefixText: '+1'
),
keyboardType: TextInputType.phone,
onSaved: (String value) { person.phoneNumber = value; },
@ -147,6 +148,16 @@ class TextFormFieldDemoState extends State<TextFormFieldDemo> {
),
maxLines: 3,
),
new TextFormField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Salary',
prefixText: '\$',
suffixText: 'USD',
suffixStyle: const TextStyle(color: Colors.green)
),
maxLines: 1,
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[

View file

@ -37,6 +37,10 @@ class InputDecoration {
this.errorStyle,
this.isDense: false,
this.hideDivider: false,
this.prefixText,
this.prefixStyle,
this.suffixText,
this.suffixStyle,
}) : isCollapsed = false;
/// Creates a decoration that is the same size as the input field.
@ -55,7 +59,11 @@ class InputDecoration {
errorStyle = null,
isDense = false,
isCollapsed = true,
hideDivider = true;
hideDivider = true,
prefixText = null,
prefixStyle = null,
suffixText = null,
suffixStyle = null;
/// An icon to show before the input field.
///
@ -108,7 +116,7 @@ class InputDecoration {
/// If non-null the divider, that appears below the input field is red.
final String errorText;
/// The style to use for the [errorText.
/// The style to use for the [errorText].
///
/// If null, defaults of a value derived from the base [TextStyle] for the
/// input field and the current [Theme].
@ -133,6 +141,28 @@ class InputDecoration {
/// Defaults to false.
final bool hideDivider;
/// Optional text prefix to place on the line before the input.
///
/// Uses the [prefixStyle]. Uses [hintStyle] if [prefixStyle] isn't
/// specified. Prefix is not returned as part of the input.
final String prefixText;
/// The style to use for the [prefixText].
///
/// If null, defaults to the [hintStyle].
final TextStyle prefixStyle;
/// Optional text suffix to place on the line after the input.
///
/// Uses the [suffixStyle]. Uses [hintStyle] if [suffixStyle] isn't
/// specified. Suffix is not returned as part of the input.
final String suffixText;
/// The style to use for the [suffixText].
///
/// If null, defaults to the [hintStyle].
final TextStyle suffixStyle;
/// Creates a copy of this input decoration but with the given fields replaced
/// with the new values.
///
@ -147,6 +177,10 @@ class InputDecoration {
TextStyle errorStyle,
bool isDense,
bool hideDivider,
String prefixText,
TextStyle prefixStyle,
String suffixText,
TextStyle suffixStyle,
}) {
return new InputDecoration(
icon: icon ?? this.icon,
@ -158,6 +192,10 @@ class InputDecoration {
errorStyle: errorStyle ?? this.errorStyle,
isDense: isDense ?? this.isDense,
hideDivider: hideDivider ?? this.hideDivider,
prefixText: prefixText ?? this.prefixText,
prefixStyle: prefixStyle ?? this.prefixStyle,
suffixText: suffixText ?? this.suffixText,
suffixStyle: suffixStyle ?? this.suffixStyle,
);
}
@ -177,7 +215,11 @@ class InputDecoration {
&& typedOther.errorStyle == errorStyle
&& typedOther.isDense == isDense
&& typedOther.isCollapsed == isCollapsed
&& typedOther.hideDivider == hideDivider;
&& typedOther.hideDivider == hideDivider
&& typedOther.prefixText == prefixText
&& typedOther.prefixStyle == prefixStyle
&& typedOther.suffixText == suffixText
&& typedOther.suffixStyle == suffixStyle;
}
@override
@ -193,6 +235,10 @@ class InputDecoration {
isDense,
isCollapsed,
hideDivider,
prefixText,
prefixStyle,
suffixText,
suffixStyle,
);
}
@ -213,6 +259,14 @@ class InputDecoration {
description.add('isCollapsed: $isCollapsed');
if (hideDivider)
description.add('hideDivider: $hideDivider');
if (prefixText != null)
description.add('prefixText: $prefixText');
if (prefixStyle != null)
description.add('prefixStyle: $prefixStyle');
if (suffixText != null)
description.add('suffixText: $suffixText');
if (suffixStyle != null)
description.add('suffixStyle: $suffixStyle');
return 'InputDecoration(${description.join(', ')})';
}
}
@ -293,7 +347,7 @@ class InputDecorator extends StatelessWidget {
return themeData.hintColor;
}
Widget _buildContent(Color borderColor, double topPadding, bool isDense) {
Widget _buildContent(Color borderColor, double topPadding, bool isDense, Widget inputChild) {
final double bottomPadding = isDense ? 8.0 : 1.0;
const double bottomBorder = 2.0;
final double bottomHeight = isDense ? 14.0 : 18.0;
@ -305,7 +359,7 @@ class InputDecorator extends StatelessWidget {
return new Container(
margin: margin + const EdgeInsets.only(bottom: bottomBorder),
padding: padding,
child: child,
child: inputChild,
);
}
@ -322,7 +376,7 @@ class InputDecorator extends StatelessWidget {
),
),
),
child: child,
child: inputChild,
);
}
@ -348,7 +402,7 @@ class InputDecorator extends StatelessWidget {
final List<Widget> stackChildren = <Widget>[];
// If we're not focused, there's not value, and labelText was provided,
// If we're not focused, there's no value, and labelText was provided,
// then the label appears where the hint would. And we will not show
// the hintText.
final bool hasInlineLabel = !isFocused && labelText != null && isEmpty;
@ -402,11 +456,33 @@ class InputDecorator extends StatelessWidget {
);
}
Widget inputChild;
if (!hasInlineLabel && (!isEmpty || hintText == null) &&
(decoration?.prefixText != null || decoration?.suffixText != null)) {
final List<Widget> rowContents = <Widget>[];
if (decoration.prefixText != null) {
rowContents.add(
new Text(decoration.prefixText,
style: decoration.prefixStyle ?? hintStyle)
);
}
rowContents.add(new Expanded(child: child));
if (decoration.suffixText != null) {
rowContents.add(
new Text(decoration.suffixText,
style: decoration.suffixStyle ?? hintStyle)
);
}
inputChild = new Row(children: rowContents);
} else {
inputChild = child;
}
if (isCollapsed) {
stackChildren.add(child);
stackChildren.add(inputChild);
} else {
final Color borderColor = errorText == null ? activeColor : themeData.errorColor;
stackChildren.add(_buildContent(borderColor, topPadding, isDense));
stackChildren.add(_buildContent(borderColor, topPadding, isDense, inputChild));
}
if (!isDense && errorText != null) {

View file

@ -801,6 +801,248 @@ void main() {
expect(hintText.style, hintStyle);
});
testWidgets('TextField with specified prefixStyle', (WidgetTester tester) async {
final TextStyle prefixStyle = new TextStyle(
color: Colors.pink[500],
fontSize: 10.0,
);
Widget builder() {
return new Center(
child: new Material(
child: new TextField(
decoration: new InputDecoration(
prefixText: 'Prefix:',
prefixStyle: prefixStyle,
),
),
),
);
}
await tester.pumpWidget(builder());
final Text prefixText = tester.widget(find.text('Prefix:'));
expect(prefixText.style, prefixStyle);
});
testWidgets('TextField with specified suffixStyle', (WidgetTester tester) async {
final TextStyle suffixStyle = new TextStyle(
color: Colors.pink[500],
fontSize: 10.0,
);
Widget builder() {
return new Center(
child: new Material(
child: new TextField(
decoration: new InputDecoration(
suffixText: '.com',
suffixStyle: suffixStyle,
),
),
),
);
}
await tester.pumpWidget(builder());
final Text suffixText = tester.widget(find.text('.com'));
expect(suffixText.style, suffixStyle);
});
testWidgets('TextField prefix and suffix appear correctly with no hint or label',
(WidgetTester tester) async {
final Key secondKey = new UniqueKey();
Widget innerBuilder() {
return new Center(
child: new Material(
child: new Column(
children: <Widget>[
const TextField(
decoration: const InputDecoration(
labelText: 'First',
),
),
new TextField(
key: secondKey,
decoration: const InputDecoration(
prefixText: 'Prefix',
suffixText: 'Suffix',
),
),
],
),
),
);
}
Widget builder() => overlay(innerBuilder());
await tester.pumpWidget(builder());
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
// Focus the Input. The prefix should still display.
await tester.tap(find.byKey(secondKey));
await tester.pump();
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
// Enter some text, and the prefix should still display.
await tester.enterText(find.byKey(secondKey), "Hi");
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
});
testWidgets('TextField prefix and suffix appear correctly with hint text',
(WidgetTester tester) async {
final TextStyle hintStyle = new TextStyle(
color: Colors.pink[500],
fontSize: 10.0,
);
final Key secondKey = new UniqueKey();
Widget innerBuilder() {
return new Center(
child: new Material(
child: new Column(
children: <Widget>[
const TextField(
decoration: const InputDecoration(
labelText: 'First',
),
),
new TextField(
key: secondKey,
decoration: new InputDecoration(
hintText: 'Hint',
hintStyle: hintStyle,
prefixText: 'Prefix',
suffixText: 'Suffix',
),
),
],
),
),
);
}
Widget builder() => overlay(innerBuilder());
await tester.pumpWidget(builder());
// Neither the prefix or the suffix should initially be visible, only the hint.
expect(find.text('Prefix'), findsNothing);
expect(find.text('Suffix'), findsNothing);
expect(find.text('Hint'), findsOneWidget);
await tester.tap(find.byKey(secondKey));
await tester.pump();
// Focus the Input. The hint should display, but not the prefix and suffix.
expect(find.text('Prefix'), findsNothing);
expect(find.text('Suffix'), findsNothing);
expect(find.text('Hint'), findsOneWidget);
// Enter some text, and the hint should disappear and the prefix and suffix
// should appear.
await tester.enterText(find.byKey(secondKey), "Hi");
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
// It's onstage, but animated to zero opacity.
expect(find.text('Hint'), findsOneWidget);
final Element target = tester.element(find.text('Hint'));
final Opacity opacity = target.ancestorWidgetOfExactType(Opacity);
expect(opacity, isNotNull);
expect(opacity.opacity, equals(0.0));
// Check and make sure that the right styles were applied.
final Text prefixText = tester.widget(find.text('Prefix'));
expect(prefixText.style, hintStyle);
final Text suffixText = tester.widget(find.text('Suffix'));
expect(suffixText.style, hintStyle);
});
testWidgets('TextField prefix and suffix appear correctly with label text',
(WidgetTester tester) async {
final TextStyle prefixStyle = new TextStyle(
color: Colors.pink[500],
fontSize: 10.0,
);
final TextStyle suffixStyle = new TextStyle(
color: Colors.green[500],
fontSize: 12.0,
);
final Key secondKey = new UniqueKey();
Widget innerBuilder() {
return new Center(
child: new Material(
child: new Column(
children: <Widget>[
const TextField(
decoration: const InputDecoration(
labelText: 'First',
),
),
new TextField(
key: secondKey,
decoration: new InputDecoration(
labelText: 'Label',
prefixText: 'Prefix',
prefixStyle: prefixStyle,
suffixText: 'Suffix',
suffixStyle: suffixStyle,
),
),
],
),
),
);
}
Widget builder() => overlay(innerBuilder());
await tester.pumpWidget(builder());
// Not focused. The prefix should not display, but the label should.
expect(find.text('Prefix'), findsNothing);
expect(find.text('Suffix'), findsNothing);
expect(find.text('Label'), findsOneWidget);
await tester.tap(find.byKey(secondKey));
await tester.pump();
// Focus the input. The label should display, and also the prefix.
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
expect(find.text('Label'), findsOneWidget);
// Enter some text, and the label should stay and the prefix should
// remain.
await tester.enterText(find.byKey(secondKey), "Hi");
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Prefix'), findsOneWidget);
expect(find.text('Suffix'), findsOneWidget);
expect(find.text('Label'), findsOneWidget);
// Check and make sure that the right styles were applied.
final Text prefixText = tester.widget(find.text('Prefix'));
expect(prefixText.style, prefixStyle);
final Text suffixText = tester.widget(find.text('Suffix'));
expect(suffixText.style, suffixStyle);
});
testWidgets('TextField label text animates', (WidgetTester tester) async {
final Key secondKey = new UniqueKey();
@ -1006,7 +1248,7 @@ void main() {
});
testWidgets(
'Cannot enter new lines onto single line TextField',
'Cannot enter new lines onto single line TextField',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
@ -1021,13 +1263,13 @@ void main() {
);
testWidgets(
'Injected formatters are chained',
'Injected formatters are chained',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(new Material(
child: new TextField(
controller: textController,
controller: textController,
decoration: null,
inputFormatters: <TextInputFormatter> [
new BlacklistingTextInputFormatter(
@ -1045,13 +1287,13 @@ void main() {
);
testWidgets(
'Chained formatters are in sequence',
'Chained formatters are in sequence',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
await tester.pumpWidget(new Material(
child: new TextField(
controller: textController,
controller: textController,
decoration: null,
maxLines: 2,
inputFormatters: <TextInputFormatter> [
@ -1067,15 +1309,15 @@ void main() {
await tester.enterText(find.byType(TextField), 'a1b2c3');
// The first formatter turns it into
// 12\n112\n212\n3
// The second formatter turns it into
// The second formatter turns it into
// \n1\n2\n3
// Multiline is allowed since maxLine != 1.
// Multiline is allowed since maxLine != 1.
expect(textController.text, '\n1\n2\n3');
}
);
testWidgets(
'Pasted values are formatted',
'Pasted values are formatted',
(WidgetTester tester) async {
final TextEditingController textController = new TextEditingController();
@ -1083,7 +1325,7 @@ void main() {
return overlay(new Center(
child: new Material(
child: new TextField(
controller: textController,
controller: textController,
decoration: null,
inputFormatters: <TextInputFormatter> [
WhitelistingTextInputFormatter.digitsOnly,
@ -1104,7 +1346,7 @@ void main() {
await tester.tapAt(textOffsetToPosition(tester, '123'.indexOf('2')));
await tester.pumpWidget(builder());
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints =
final List<TextSelectionPoint> endpoints =
renderEditable.getEndpointsForSelection(textController.selection);
await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0));
await tester.pumpWidget(builder());