From a7360d24fef24ee87b21de2e2bc9349c93aaf77a Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Wed, 16 Nov 2016 08:53:07 -0800 Subject: [PATCH] Factor out Input's layout: add InputContainer etc (#6881) --- packages/flutter/lib/src/material/input.dart | 406 ++++++++++++------- 1 file changed, 256 insertions(+), 150 deletions(-) diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index 50d3bdb43f..5fd1ece11b 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -10,12 +10,31 @@ import 'debug.dart'; import 'icon.dart'; import 'icon_theme.dart'; import 'icon_theme_data.dart'; -import 'material.dart'; import 'text_selection.dart'; import 'theme.dart'; export 'package:flutter/services.dart' show TextInputType; +const Duration _kTransitionDuration = const Duration(milliseconds: 200); +const Curve _kTransitionCurve = Curves.fastOutSlowIn; + +/// A simple text input field. +/// +/// This widget is comparable to [Text] in that it does not include a margin +/// or any decoration outside the text itself. It is useful for applications, +/// like a search box, that don't need any additional decoration. It should +/// also be useful in custom widgets that support text input. +/// +/// The [value] field must be updated each time the [onChanged] callback is +/// invoked. Be sure to include the full [value] provided by the [onChanged] +/// callback, or information like the current selection will be lost. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// See also: +/// +/// * [Input], which adds a label, a divider below the text field, and support for +/// an error message. class InputField extends StatefulWidget { InputField({ Key key, @@ -119,7 +138,8 @@ class _InputFieldState extends State { ]; if (config.hintText != null && value.text.isEmpty) { - TextStyle hintStyle = themeData.textTheme.subhead.copyWith(color: themeData.hintColor); + + TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor); stackChildren.add( new Positioned( left: 0.0, @@ -135,14 +155,212 @@ class _InputFieldState extends State { } } -/// A material design text input field. +/// Displays the visual elements of a material design text field around an +/// arbitrary child widget. +/// +/// Use InputContainer to create widgets that look and behave like the [Input] +/// widget. /// /// Requires one of its ancestors to be a [Material] widget. /// +/// See also: +/// +/// * [Input], which combines an [InputContainer] with an [InputField]. +class InputContainer extends StatefulWidget { + InputContainer({ + Key key, + this.focused: false, + this.isEmpty: false, + this.icon, + this.labelText, + this.hintText, + this.errorText, + this.style, + this.isDense: false, + this.child, + }) : super(key: key); + + /// An icon to show adjacent to the input field. + /// + /// The size and color of the icon is configured automatically using an + /// [IconTheme] and therefore does not need to be explicitly given in the + /// icon widget. + /// + /// See [Icon], [ImageIcon]. + final Widget icon; + + /// Text that appears above the child or over it, if isEmpty is true. + final String labelText; + + /// Text that appears over the child if isEmpty is true and labelText is null. + final String hintText; + + /// Text that appears below the child. If errorText is non-null the divider + /// that appears below the child is red. + final String errorText; + + /// The style to use for the hint. It's also used for the label when the label + /// appears over the child. + final TextStyle style; + + /// Whether the input container is part of a dense form (i.e., uses less vertical space). + final bool isDense; + + /// True if the hint and label should be displayed as if the child had the focus. + final bool focused; + + /// Should the hint and label be displayed as if no value had been input + /// to the child. + final bool isEmpty; + + final Widget child; + + @override + _InputContainerState createState() => new _InputContainerState(); +} + +class _InputContainerState extends State { + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + ThemeData themeData = Theme.of(context); + String errorText = config.errorText; + + final TextStyle textStyle = config.style ?? themeData.textTheme.subhead; + Color activeColor = themeData.hintColor; + if (config.focused) { + switch (themeData.brightness) { + case Brightness.dark: + activeColor = themeData.accentColor; + break; + case Brightness.light: + activeColor = themeData.primaryColor; + break; + } + } + double topPadding = config.isDense ? 12.0 : 16.0; + + List stackChildren = []; + + // If we're not focused, there's not value, and labelText was provided, + // then the label appears where the hint would. And we will not show + // the hintText. + final bool hasInlineLabel = !config.focused && config.labelText != null && config.isEmpty; + + if (config.labelText != null) { + final TextStyle labelStyle = hasInlineLabel ? + textStyle.copyWith(color: themeData.hintColor) : + themeData.textTheme.caption.copyWith(color: activeColor); + + final double topPaddingIncrement = themeData.textTheme.caption.fontSize + (config.isDense ? 4.0 : 8.0); + double top = topPadding; + if (hasInlineLabel) + top += topPaddingIncrement + textStyle.fontSize - labelStyle.fontSize; + + stackChildren.add( + new AnimatedPositioned( + left: 0.0, + top: top, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + child: new Text(config.labelText, style: labelStyle), + ), + ); + + topPadding += topPaddingIncrement; + } + + if (config.hintText != null && config.isEmpty && !hasInlineLabel) { + TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor); + stackChildren.add( + new Positioned( + left: 0.0, + top: topPadding + textStyle.fontSize - hintStyle.fontSize, + child: new IgnorePointer( + child: new Text(config.hintText, style: hintStyle), + ), + ), + ); + } + + Color borderColor = activeColor; + double bottomPadding = 8.0; + double bottomBorder = config.focused ? 2.0 : 1.0; + double bottomHeight = config.isDense ? 14.0 : 18.0; + + if (errorText != null) { + borderColor = themeData.errorColor; + bottomBorder = 2.0; + if (!config.isDense) + bottomPadding = 1.0; + } + + EdgeInsets padding = new EdgeInsets.only(top: topPadding, bottom: bottomPadding); + Border border = new Border( + bottom: new BorderSide( + color: borderColor, + width: bottomBorder, + ) + ); + EdgeInsets margin = new EdgeInsets.only(bottom: bottomHeight - (bottomPadding + bottomBorder)); + + stackChildren.add(new AnimatedContainer( + margin: margin, + padding: padding, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + decoration: new BoxDecoration( + border: border, + ), + child: config.child, + )); + + if (errorText != null && !config.isDense) { + TextStyle errorStyle = themeData.textTheme.caption.copyWith(color: themeData.errorColor); + stackChildren.add(new Positioned( + left: 0.0, + bottom: 0.0, + child: new Text(errorText, style: errorStyle) + )); + } + + Widget textField = new Stack(children: stackChildren); + + if (config.icon != null) { + double iconSize = config.isDense ? 18.0 : 24.0; + double iconTop = topPadding + (textStyle.fontSize - iconSize) / 2.0; + textField = new Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + new Container( + margin: new EdgeInsets.only(right: 16.0, top: iconTop), + width: config.isDense ? 40.0 : 48.0, + child: new IconTheme.merge( + context: context, + data: new IconThemeData( + color: config.focused ? activeColor : Colors.black45, + size: config.isDense ? 18.0 : 24.0 + ), + child: config.icon + ) + ), + new Flexible(child: textField) + ] + ); + } + + return textField; + } +} + +/// A material design text input field. +/// /// The [value] field must be updated each time the [onChanged] callback is /// invoked. Be sure to include the full [value] provided by the [onChanged] /// callback, or information like the current selection will be lost. /// +/// Requires one of its ancestors to be a [Material] widget. +/// /// See also: /// /// * @@ -155,8 +373,8 @@ class Input extends StatefulWidget { /// /// By default, the input uses a keyboard appropriate for text entry. // - // Note: If you change this constructor signature, please also update - // InputFormField below. + // If you change this constructor signature, please also update + // InputContainer, InputFormField, InputField. Input({ Key key, this.value, @@ -231,161 +449,49 @@ class Input extends StatefulWidget { _InputState createState() => new _InputState(); } -const Duration _kTransitionDuration = const Duration(milliseconds: 200); -const Curve _kTransitionCurve = Curves.fastOutSlowIn; - class _InputState extends State { - GlobalKey<_InputFieldState> _inputFieldKey = new GlobalKey<_InputFieldState>(debugLabel: '_InputState _inputFieldKey'); - GlobalKey<_InputFieldState> _focusKey = new GlobalKey(debugLabel: '_InputState _focusKey'); + final GlobalKey<_InputFieldState> _inputFieldKey = new GlobalKey<_InputFieldState>(); + final GlobalKey _focusKey = new GlobalKey(); GlobalKey get focusKey => config.key is GlobalKey ? config.key : _focusKey; @override Widget build(BuildContext context) { - assert(debugCheckHasMaterial(context)); - ThemeData themeData = Theme.of(context); - BuildContext focusContext = focusKey.currentContext; - bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus); - InputValue value = config.value ?? InputValue.empty; - String errorText = config.errorText; - - TextStyle textStyle = config.style ?? themeData.textTheme.subhead; - Color activeColor = themeData.hintColor; - if (focused) { - switch (themeData.brightness) { - case Brightness.dark: - activeColor = themeData.accentColor; - break; - case Brightness.light: - activeColor = themeData.primaryColor; - break; - } - } - double topPadding = config.isDense ? 12.0 : 16.0; - - List stackChildren = []; - - final bool hasInlineLabel = config.labelText != null && !focused && !value.text.isNotEmpty; - if (config.labelText != null) { - final TextStyle labelStyle = hasInlineLabel ? - themeData.textTheme.subhead.copyWith(color: themeData.hintColor) : - themeData.textTheme.caption.copyWith(color: activeColor); - - final double topPaddingIncrement = themeData.textTheme.caption.fontSize + (config.isDense ? 4.0 : 8.0); - double top = topPadding; - if (hasInlineLabel) - top += topPaddingIncrement + textStyle.fontSize - labelStyle.fontSize; - - stackChildren.add( - new AnimatedPositioned( - left: 0.0, - top: top, - duration: _kTransitionDuration, - curve: _kTransitionCurve, - child: new AnimatedOpacity( - opacity: focused ? 1.0 : 0.0, - curve: _kTransitionCurve, - duration: _kTransitionDuration, - child: new Text(config.labelText, style: labelStyle) - ), - ), - ); - - topPadding += topPaddingIncrement; - } - - Color borderColor = activeColor; - double bottomPadding = 8.0; - double bottomBorder = focused ? 2.0 : 1.0; - double bottomHeight = config.isDense ? 14.0 : 18.0; - - if (errorText != null) { - borderColor = themeData.errorColor; - bottomBorder = 2.0; - if (!config.isDense) - bottomPadding = 1.0; - } - - EdgeInsets padding = new EdgeInsets.only(top: topPadding, bottom: bottomPadding); - Border border = new Border( - bottom: new BorderSide( - color: borderColor, - width: bottomBorder, - ) - ); - EdgeInsets margin = new EdgeInsets.only(bottom: bottomHeight - (bottomPadding + bottomBorder)); - - stackChildren.add(new AnimatedContainer( - margin: margin, - padding: padding, - duration: _kTransitionDuration, - curve: _kTransitionCurve, - decoration: new BoxDecoration( - border: border, - ), - // Since the focusKey may have been created here, defer building the - // InputField until the focusKey's context has been set. This is necessary - // because our descendants may check the focus, like Focus.at(focusContext), - // when they build. - child: new Builder( - builder: (BuildContext context) { - return new InputField( - key: _inputFieldKey, - focusKey: focusKey, - value: value, - style: textStyle, - hideText: config.hideText, - maxLines: config.maxLines, - keyboardType: config.keyboardType, - hintText: config.hintText, - onChanged: config.onChanged, - onSubmitted: config.onSubmitted, - ); - } - ), - )); - - if (errorText != null && !config.isDense) { - TextStyle errorStyle = themeData.textTheme.caption.copyWith(color: themeData.errorColor); - stackChildren.add(new Positioned( - left: 0.0, - bottom: 0.0, - child: new Text(errorText, style: errorStyle) - )); - } - - Widget child = new Stack(children: stackChildren); - - if (config.icon != null) { - double iconSize = config.isDense ? 18.0 : 24.0; - double iconTop = topPadding + (textStyle.fontSize - iconSize) / 2.0; - child = new Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Container( - margin: new EdgeInsets.only(right: 16.0, top: iconTop), - width: config.isDense ? 40.0 : 48.0, - child: new IconTheme.merge( - context: context, - data: new IconThemeData( - color: focused ? activeColor : Colors.black45, - size: config.isDense ? 18.0 : 24.0 - ), - child: config.icon - ) - ), - new Flexible(child: child) - ] - ); - } - return new GestureDetector( - key: config.key is GlobalKey ? null : focusKey, - behavior: HitTestBehavior.opaque, + key: focusKey == _focusKey ? _focusKey : null, onTap: () { _inputFieldKey.currentState?.requestKeyboard(); }, - child: child, + // Since the focusKey may have been created here, defer building the + // InputContainer until the focusKey's context has been set. This is + // necessary because we're passing the value of Focus.at() along. + child: new Builder( + builder: (BuildContext context) { + final bool focused = Focus.at(focusKey.currentContext, autofocus: config.autofocus); + final bool isEmpty = (config.value ?? InputValue.empty).text.isEmpty; + return new InputContainer( + focused: focused, + isEmpty: isEmpty, + icon: config.icon, + labelText: config.labelText, + hintText: config.hintText, + errorText: config.errorText, + style: config.style, + isDense: config.isDense, + child: new InputField( + key: _inputFieldKey, + focusKey: focusKey, + value: config.value, + style: config.style, + hideText: config.hideText, + maxLines: config.maxLines, + keyboardType: config.keyboardType, + onChanged: config.onChanged, + onSubmitted: config.onSubmitted, + ), + ); + }, + ), ); } }