Commit 0b31c699 authored by Adam Barth's avatar Adam Barth Committed by GitHub

Make it possible to center the text in a TextField (#9140)

Also, fix an issue where hint text wasn't visible when the
InputDecoration was collapsed.

Fixes #8541
parent 9946355e
...@@ -238,6 +238,7 @@ class InputDecorator extends StatelessWidget { ...@@ -238,6 +238,7 @@ class InputDecorator extends StatelessWidget {
Key key, Key key,
@required this.decoration, @required this.decoration,
this.baseStyle, this.baseStyle,
this.textAlign,
this.isFocused: false, this.isFocused: false,
this.isEmpty: false, this.isEmpty: false,
this.child, this.child,
...@@ -252,6 +253,9 @@ class InputDecorator extends StatelessWidget { ...@@ -252,6 +253,9 @@ class InputDecorator extends StatelessWidget {
/// If null, defaults to a text style from the current [Theme]. /// If null, defaults to a text style from the current [Theme].
final TextStyle baseStyle; final TextStyle baseStyle;
/// How the text in the decoration should be aligned horizontally.
final TextAlign textAlign;
/// Whether the input field has focus. /// Whether the input field has focus.
/// ///
/// Determines the position of the label text and the color of the divider. /// Determines the position of the label text and the color of the divider.
...@@ -342,7 +346,7 @@ class InputDecorator extends StatelessWidget { ...@@ -342,7 +346,7 @@ class InputDecorator extends StatelessWidget {
final Color activeColor = _getActiveColor(themeData); final Color activeColor = _getActiveColor(themeData);
double topPadding = isDense ? 12.0 : 16.0; double topPadding = isCollapsed ? 0.0 : (isDense ? 12.0 : 16.0);
final List<Widget> stackChildren = <Widget>[]; final List<Widget> stackChildren = <Widget>[];
...@@ -383,12 +387,18 @@ class InputDecorator extends StatelessWidget { ...@@ -383,12 +387,18 @@ class InputDecorator extends StatelessWidget {
stackChildren.add( stackChildren.add(
new Positioned( new Positioned(
left: 0.0, left: 0.0,
right: 0.0,
top: topPadding + baseStyle.fontSize - hintStyle.fontSize, top: topPadding + baseStyle.fontSize - hintStyle.fontSize,
child: new AnimatedOpacity( child: new AnimatedOpacity(
opacity: (isEmpty && !hasInlineLabel) ? 1.0 : 0.0, opacity: (isEmpty && !hasInlineLabel) ? 1.0 : 0.0,
duration: _kTransitionDuration, duration: _kTransitionDuration,
curve: _kTransitionCurve, curve: _kTransitionCurve,
child: new Text(hintText, style: hintStyle), child: new Text(
hintText,
style: hintStyle,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
),
), ),
), ),
); );
...@@ -406,8 +416,14 @@ class InputDecorator extends StatelessWidget { ...@@ -406,8 +416,14 @@ class InputDecorator extends StatelessWidget {
final TextStyle errorStyle = decoration.errorStyle ?? themeData.textTheme.caption.copyWith(color: themeData.errorColor); final TextStyle errorStyle = decoration.errorStyle ?? themeData.textTheme.caption.copyWith(color: themeData.errorColor);
stackChildren.add(new Positioned( stackChildren.add(new Positioned(
left: 0.0, left: 0.0,
right: 0.0,
bottom: 0.0, bottom: 0.0,
child: new Text(errorText, style: errorStyle) child: new Text(
errorText,
style: errorStyle,
textAlign: textAlign,
overflow: TextOverflow.ellipsis,
),
)); ));
} }
......
...@@ -69,6 +69,7 @@ class TextField extends StatefulWidget { ...@@ -69,6 +69,7 @@ class TextField extends StatefulWidget {
this.decoration: const InputDecoration(), this.decoration: const InputDecoration(),
this.keyboardType: TextInputType.text, this.keyboardType: TextInputType.text,
this.style, this.style,
this.textAlign,
this.autofocus: false, this.autofocus: false,
this.obscureText: false, this.obscureText: false,
this.maxLines: 1, this.maxLines: 1,
...@@ -88,7 +89,7 @@ class TextField extends StatefulWidget { ...@@ -88,7 +89,7 @@ class TextField extends StatefulWidget {
/// The decoration to show around the text field. /// The decoration to show around the text field.
/// ///
/// By default, draws a horizontal line under the input field but can be /// By default, draws a horizontal line under the text field but can be
/// configured to show an icon, label, hint text, and error text. /// configured to show an icon, label, hint text, and error text.
/// ///
/// Set this field to null to remove the decoration entirely (including the /// Set this field to null to remove the decoration entirely (including the
...@@ -105,10 +106,13 @@ class TextField extends StatefulWidget { ...@@ -105,10 +106,13 @@ class TextField extends StatefulWidget {
/// If null, defaults to a text style from the current [Theme]. /// If null, defaults to a text style from the current [Theme].
final TextStyle style; final TextStyle style;
/// Whether this input field should focus itself if nothing else is already /// How the text being edited should be aligned horizontally.
final TextAlign textAlign;
/// Whether this text field should focus itself if nothing else is already
/// focused. /// focused.
/// ///
/// If true, the keyboard will open as soon as this input obtains focus. /// If true, the keyboard will open as soon as this text field obtains focus.
/// Otherwise, the keyboard is only shown after the user taps the text field. /// Otherwise, the keyboard is only shown after the user taps the text field.
/// ///
/// Defaults to false. /// Defaults to false.
...@@ -118,8 +122,8 @@ class TextField extends StatefulWidget { ...@@ -118,8 +122,8 @@ class TextField extends StatefulWidget {
/// Whether to hide the text being edited (e.g., for passwords). /// Whether to hide the text being edited (e.g., for passwords).
/// ///
/// When this is set to true, all the characters in the input are replaced by /// When this is set to true, all the characters in the text field are
/// U+2022 BULLET characters (•). /// replaced by U+2022 BULLET characters (•).
/// ///
/// Defaults to false. /// Defaults to false.
final bool obscureText; final bool obscureText;
...@@ -209,6 +213,7 @@ class _TextFieldState extends State<TextField> { ...@@ -209,6 +213,7 @@ class _TextFieldState extends State<TextField> {
focusNode: focusNode, focusNode: focusNode,
keyboardType: config.keyboardType, keyboardType: config.keyboardType,
style: style, style: style,
textAlign: config.textAlign,
autofocus: config.autofocus, autofocus: config.autofocus,
obscureText: config.obscureText, obscureText: config.obscureText,
maxLines: config.maxLines, maxLines: config.maxLines,
...@@ -227,6 +232,7 @@ class _TextFieldState extends State<TextField> { ...@@ -227,6 +232,7 @@ class _TextFieldState extends State<TextField> {
return new InputDecorator( return new InputDecorator(
decoration: config.decoration, decoration: config.decoration,
baseStyle: config.style, baseStyle: config.style,
textAlign: config.textAlign,
isFocused: focusNode.hasFocus, isFocused: focusNode.hasFocus,
isEmpty: controller.value.text.isEmpty, isEmpty: controller.value.text.isEmpty,
child: child, child: child,
......
...@@ -318,23 +318,35 @@ class TextPainter { ...@@ -318,23 +318,35 @@ class TextPainter {
return new Offset(dx, box.top); return new Offset(dx, box.top);
} }
Offset get _emptyOffset {
// TODO(abarth): Handle the directionality of the text painter itself.
switch (textAlign ?? TextAlign.left) {
case TextAlign.left:
case TextAlign.justify:
return Offset.zero;
case TextAlign.right:
return new Offset(width, 0.0);
case TextAlign.center:
return new Offset(width / 2.0, 0.0);
}
return null;
}
/// Returns the offset at which to paint the caret. /// Returns the offset at which to paint the caret.
/// ///
/// Valid only after [layout] has been called. /// Valid only after [layout] has been called.
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) { Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
assert(!_needsLayout); assert(!_needsLayout);
final int offset = position.offset; final int offset = position.offset;
// TODO(abarth): Handle the directionality of the text painter itself.
const Offset emptyOffset = Offset.zero;
switch (position.affinity) { switch (position.affinity) {
case TextAffinity.upstream: case TextAffinity.upstream:
return _getOffsetFromUpstream(offset, caretPrototype) return _getOffsetFromUpstream(offset, caretPrototype)
?? _getOffsetFromDownstream(offset, caretPrototype) ?? _getOffsetFromDownstream(offset, caretPrototype)
?? emptyOffset; ?? _emptyOffset;
case TextAffinity.downstream: case TextAffinity.downstream:
return _getOffsetFromDownstream(offset, caretPrototype) return _getOffsetFromDownstream(offset, caretPrototype)
?? _getOffsetFromUpstream(offset, caretPrototype) ?? _getOffsetFromUpstream(offset, caretPrototype)
?? emptyOffset; ?? _emptyOffset;
} }
assert(position.affinity != null); assert(position.affinity != null);
return null; return null;
......
...@@ -47,6 +47,7 @@ class RenderEditable extends RenderBox { ...@@ -47,6 +47,7 @@ class RenderEditable extends RenderBox {
/// Creates a render object for a single line of editable text. /// Creates a render object for a single line of editable text.
RenderEditable({ RenderEditable({
TextSpan text, TextSpan text,
TextAlign textAlign,
Color cursorColor, Color cursorColor,
bool showCursor: false, bool showCursor: false,
int maxLines: 1, int maxLines: 1,
...@@ -55,7 +56,7 @@ class RenderEditable extends RenderBox { ...@@ -55,7 +56,7 @@ class RenderEditable extends RenderBox {
TextSelection selection, TextSelection selection,
@required ViewportOffset offset, @required ViewportOffset offset,
this.onSelectionChanged, this.onSelectionChanged,
}) : _textPainter = new TextPainter(text: text, textScaleFactor: textScaleFactor), }) : _textPainter = new TextPainter(text: text, textAlign: textAlign, textScaleFactor: textScaleFactor),
_cursorColor = cursorColor, _cursorColor = cursorColor,
_showCursor = showCursor, _showCursor = showCursor,
_maxLines = maxLines, _maxLines = maxLines,
...@@ -87,6 +88,15 @@ class RenderEditable extends RenderBox { ...@@ -87,6 +88,15 @@ class RenderEditable extends RenderBox {
markNeedsLayout(); markNeedsLayout();
} }
/// How the text should be aligned horizontally.
TextAlign get textAlign => _textPainter.textAlign;
set textAlign(TextAlign value) {
if (_textPainter.textAlign == value)
return;
_textPainter.textAlign = value;
markNeedsPaint();
}
/// The color to use when painting the cursor. /// The color to use when painting the cursor.
Color get cursorColor => _cursorColor; Color get cursorColor => _cursorColor;
Color _cursorColor; Color _cursorColor;
...@@ -225,7 +235,7 @@ class RenderEditable extends RenderBox { ...@@ -225,7 +235,7 @@ class RenderEditable extends RenderBox {
// TODO(mpcomplete): We should be more disciplined about when we dirty the // TODO(mpcomplete): We should be more disciplined about when we dirty the
// layout state of the text painter so that we can know that the layout is // layout state of the text painter so that we can know that the layout is
// clean at this point. // clean at this point.
_textPainter.layout(maxWidth: _maxContentWidth); _layoutText();
final Offset paintOffset = _paintOffset; final Offset paintOffset = _paintOffset;
...@@ -261,12 +271,6 @@ class RenderEditable extends RenderBox { ...@@ -261,12 +271,6 @@ class RenderEditable extends RenderBox {
double get _preferredLineHeight => _textPainter.preferredLineHeight; double get _preferredLineHeight => _textPainter.preferredLineHeight;
double get _maxContentWidth {
if (_maxLines > 1)
return constraints.maxWidth - (_kCaretGap + _kCaretWidth);
return double.INFINITY;
}
@override @override
double computeMinIntrinsicHeight(double width) { double computeMinIntrinsicHeight(double width) {
return _preferredLineHeight; return _preferredLineHeight;
...@@ -333,11 +337,19 @@ class RenderEditable extends RenderBox { ...@@ -333,11 +337,19 @@ class RenderEditable extends RenderBox {
Rect _caretPrototype; Rect _caretPrototype;
void _layoutText() {
final double caretMargin = _kCaretGap + _kCaretWidth;
final double maxWidth = _maxLines > 1 ?
math.max(0.0, constraints.maxWidth - caretMargin) : double.INFINITY;
final double minWidth = math.max(0.0, constraints.minWidth - caretMargin);
_textPainter.layout(minWidth: minWidth, maxWidth: maxWidth);
}
@override @override
void performLayout() { void performLayout() {
_caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, _preferredLineHeight - 2.0 * _kCaretHeightOffset); _caretPrototype = new Rect.fromLTWH(0.0, _kCaretHeightOffset, _kCaretWidth, _preferredLineHeight - 2.0 * _kCaretHeightOffset);
_selectionRects = null; _selectionRects = null;
_textPainter.layout(maxWidth: _maxContentWidth); _layoutText();
size = new Size(constraints.maxWidth, constraints.constrainHeight( size = new Size(constraints.maxWidth, constraints.constrainHeight(
_textPainter.height.clamp(_preferredLineHeight, _preferredLineHeight * _maxLines) _textPainter.height.clamp(_preferredLineHeight, _preferredLineHeight * _maxLines)
)); ));
......
...@@ -93,6 +93,7 @@ class EditableText extends StatefulWidget { ...@@ -93,6 +93,7 @@ class EditableText extends StatefulWidget {
this.obscureText: false, this.obscureText: false,
@required this.style, @required this.style,
@required this.cursorColor, @required this.cursorColor,
this.textAlign,
this.textScaleFactor, this.textScaleFactor,
this.maxLines: 1, this.maxLines: 1,
this.autofocus: false, this.autofocus: false,
...@@ -125,6 +126,9 @@ class EditableText extends StatefulWidget { ...@@ -125,6 +126,9 @@ class EditableText extends StatefulWidget {
/// The text style to use for the editable text. /// The text style to use for the editable text.
final TextStyle style; final TextStyle style;
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// The number of font pixels for each logical pixel. /// The number of font pixels for each logical pixel.
/// ///
/// For example, if the text scale factor is 1.5, text will be 50% larger than /// For example, if the text scale factor is 1.5, text will be 50% larger than
...@@ -407,6 +411,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient { ...@@ -407,6 +411,7 @@ class EditableTextState extends State<EditableText> implements TextInputClient {
maxLines: config.maxLines, maxLines: config.maxLines,
selectionColor: config.selectionColor, selectionColor: config.selectionColor,
textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor, textScaleFactor: config.textScaleFactor ?? MediaQuery.of(context).textScaleFactor,
textAlign: config.textAlign,
obscureText: config.obscureText, obscureText: config.obscureText,
offset: offset, offset: offset,
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
...@@ -426,6 +431,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -426,6 +431,7 @@ class _Editable extends LeafRenderObjectWidget {
this.maxLines, this.maxLines,
this.selectionColor, this.selectionColor,
this.textScaleFactor, this.textScaleFactor,
this.textAlign,
this.obscureText, this.obscureText,
this.offset, this.offset,
this.onSelectionChanged, this.onSelectionChanged,
...@@ -438,6 +444,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -438,6 +444,7 @@ class _Editable extends LeafRenderObjectWidget {
final int maxLines; final int maxLines;
final Color selectionColor; final Color selectionColor;
final double textScaleFactor; final double textScaleFactor;
final TextAlign textAlign;
final bool obscureText; final bool obscureText;
final ViewportOffset offset; final ViewportOffset offset;
final SelectionChangedHandler onSelectionChanged; final SelectionChangedHandler onSelectionChanged;
...@@ -451,6 +458,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -451,6 +458,7 @@ class _Editable extends LeafRenderObjectWidget {
maxLines: maxLines, maxLines: maxLines,
selectionColor: selectionColor, selectionColor: selectionColor,
textScaleFactor: textScaleFactor, textScaleFactor: textScaleFactor,
textAlign: textAlign,
selection: value.selection, selection: value.selection,
offset: offset, offset: offset,
onSelectionChanged: onSelectionChanged, onSelectionChanged: onSelectionChanged,
...@@ -466,6 +474,7 @@ class _Editable extends LeafRenderObjectWidget { ...@@ -466,6 +474,7 @@ class _Editable extends LeafRenderObjectWidget {
..maxLines = maxLines ..maxLines = maxLines
..selectionColor = selectionColor ..selectionColor = selectionColor
..textScaleFactor = textScaleFactor ..textScaleFactor = textScaleFactor
..textAlign = textAlign
..selection = value.selection ..selection = value.selection
..offset = offset ..offset = offset
..onSelectionChanged = onSelectionChanged; ..onSelectionChanged = onSelectionChanged;
......
...@@ -804,4 +804,52 @@ void main() { ...@@ -804,4 +804,52 @@ void main() {
expect(iconRight, equals(tester.getTopLeft(find.text('label')).x)); expect(iconRight, equals(tester.getTopLeft(find.text('label')).x));
expect(iconRight, equals(tester.getTopLeft(find.byType(EditableText)).x)); expect(iconRight, equals(tester.getTopLeft(find.byType(EditableText)).x));
}); });
testWidgets('Collapsed hint text placement', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(new Center(
child: new Material(
child: new TextField(
decoration: new InputDecoration.collapsed(
hintText: 'hint',
),
),
),
)),
);
expect(tester.getTopLeft(find.text('hint')), equals(tester.getTopLeft(find.byType(TextField))));
});
testWidgets('Can align to center', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(new Center(
child: new Material(
child: new Container(
width: 300.0,
child: new TextField(
textAlign: TextAlign.center,
decoration: null,
),
),
),
)),
);
final RenderEditable editable = findRenderEditable(tester);
Point topLeft = editable.localToGlobal(
editable.getLocalRectForCaret(new TextPosition(offset: 0)).topLeft
);
expect(topLeft.x, equals(399.0));
await tester.enterText(find.byType(EditableText), 'abcd');
await tester.pump();
topLeft = editable.localToGlobal(
editable.getLocalRectForCaret(new TextPosition(offset: 2)).topLeft
);
expect(topLeft.x, equals(399.0));
});
} }
...@@ -29,7 +29,6 @@ void main() { ...@@ -29,7 +29,6 @@ void main() {
Future<Null> checkText(String testValue) async { Future<Null> checkText(String testValue) async {
await tester.enterText(find.byType(EditableText), testValue); await tester.enterText(find.byType(EditableText), testValue);
await tester.idle();
formKey.currentState.save(); formKey.currentState.save();
// pump'ing is unnecessary because callback happens regardless of frames // pump'ing is unnecessary because callback happens regardless of frames
expect(fieldValue, equals(testValue)); expect(fieldValue, equals(testValue));
...@@ -60,7 +59,6 @@ void main() { ...@@ -60,7 +59,6 @@ void main() {
Future<Null> checkText(String testValue) async { Future<Null> checkText(String testValue) async {
await tester.enterText(find.byType(EditableText), testValue); await tester.enterText(find.byType(EditableText), testValue);
await tester.idle();
// pump'ing is unnecessary because callback happens regardless of frames // pump'ing is unnecessary because callback happens regardless of frames
expect(fieldValue, equals(testValue)); expect(fieldValue, equals(testValue));
} }
...@@ -93,7 +91,6 @@ void main() { ...@@ -93,7 +91,6 @@ void main() {
Future<Null> checkErrorText(String testValue) async { Future<Null> checkErrorText(String testValue) async {
formKey.currentState.reset(); formKey.currentState.reset();
await tester.enterText(find.byType(EditableText), testValue); await tester.enterText(find.byType(EditableText), testValue);
await tester.idle();
await tester.pumpWidget(builder(false)); await tester.pumpWidget(builder(false));
// We have to manually validate if we're not autovalidating. // We have to manually validate if we're not autovalidating.
...@@ -105,7 +102,6 @@ void main() { ...@@ -105,7 +102,6 @@ void main() {
// Try again with autovalidation. Should validate immediately. // Try again with autovalidation. Should validate immediately.
formKey.currentState.reset(); formKey.currentState.reset();
await tester.enterText(find.byType(EditableText), testValue); await tester.enterText(find.byType(EditableText), testValue);
await tester.idle();
await tester.pumpWidget(builder(true)); await tester.pumpWidget(builder(true));
expect(find.text(errorText(testValue)), findsOneWidget); expect(find.text(errorText(testValue)), findsOneWidget);
...@@ -146,7 +142,6 @@ void main() { ...@@ -146,7 +142,6 @@ void main() {
Future<Null> checkErrorText(String testValue) async { Future<Null> checkErrorText(String testValue) async {
await tester.enterText(find.byType(EditableText).first, testValue); await tester.enterText(find.byType(EditableText).first, testValue);
await tester.idle();
await tester.pump(); await tester.pump();
// Check for a new Text widget with our error text. // Check for a new Text widget with our error text.
...@@ -190,7 +185,6 @@ void main() { ...@@ -190,7 +185,6 @@ void main() {
// sanity check, make sure we can still edit the text and everything updates // sanity check, make sure we can still edit the text and everything updates
expect(inputKey.currentState.value, equals(initialValue)); expect(inputKey.currentState.value, equals(initialValue));
await tester.enterText(find.byType(EditableText), 'world'); await tester.enterText(find.byType(EditableText), 'world');
await tester.idle();
await tester.pump(); await tester.pump();
expect(inputKey.currentState.value, equals('world')); expect(inputKey.currentState.value, equals('world'));
expect(editableText.config.controller.text, equals('world')); expect(editableText.config.controller.text, equals('world'));
...@@ -221,7 +215,6 @@ void main() { ...@@ -221,7 +215,6 @@ void main() {
expect(formKey.currentState.validate(), isTrue); expect(formKey.currentState.validate(), isTrue);
await tester.enterText(find.byType(EditableText), 'Test'); await tester.enterText(find.byType(EditableText), 'Test');
await tester.idle();
await tester.pumpWidget(builder(false)); await tester.pumpWidget(builder(false));
// Form wasn't saved yet. // Form wasn't saved yet.
......
...@@ -431,6 +431,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -431,6 +431,7 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
Future<Null> enterText(Finder finder, String text) async { Future<Null> enterText(Finder finder, String text) async {
await showKeyboard(finder); await showKeyboard(finder);
testTextInput.enterText(text); testTextInput.enterText(text);
await idle();
return null; return null;
} }
} }
......
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