Commit ae899486 authored by Adam Barth's avatar Adam Barth Committed by GitHub

Rationalize text input widgets (#9119)

After this patch, there are three major text input widgets:

 * EditableText. This widget is a low-level editing control that
   interacts with the IME and displays a blinking cursor.

 * TextField. This widget is a Material Design text field, with all the
   bells and whistles. It is highly configurable and can be reduced down
   to a fairly simple control by setting its `decoration` property to
   null.

 * TextFormField. This widget is a FormField that wraps a TextField.

This patch also replaces the InputValue data model for these widgets
with a Listenable TextEditingController, which is much more flexible.

Fixes #7031
parent 91dbb3c9
......@@ -7,12 +7,12 @@ import 'package:flutter/rendering.dart' show debugDumpRenderTree;
class CardModel {
CardModel(this.value, this.height) {
inputValue = new InputValue(text: 'Item $value');
textController = new TextEditingController(text: 'Item $value');
}
int value;
double height;
int get color => ((value % 9) + 1) * 100;
InputValue inputValue;
TextEditingController textController;
Key get key => new ObjectKey(this);
}
......@@ -245,11 +245,7 @@ class CardCollectionState extends State<CardCollection> {
new Center(
child: new TextField(
key: new GlobalObjectKey(cardModel),
onChanged: (InputValue value) {
setState(() {
cardModel.inputValue = value;
});
},
controller: cardModel.textController,
),
)
: new DefaultTextStyle.merge(
......@@ -261,7 +257,7 @@ class CardCollectionState extends State<CardCollection> {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
new Text(cardModel.inputValue.text, textAlign: _textAlign),
new Text(cardModel.textController.text, textAlign: _textAlign),
],
),
),
......
......@@ -26,9 +26,11 @@ class _InputDropdown extends StatelessWidget {
Widget build(BuildContext context) {
return new InkWell(
onTap: onPressed,
child: new InputContainer(
labelText: labelText,
style: valueStyle,
child: new InputDecorator(
decoration: new InputDecoration(
labelText: labelText,
),
baseStyle: valueStyle,
child: new Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
......@@ -133,11 +135,15 @@ class _DateAndTimePickerDemoState extends State<DateAndTimePickerDemo> {
padding: const EdgeInsets.all(16.0),
children: <Widget>[
new TextField(
labelText: 'Event name',
decoration: const InputDecoration(
labelText: 'Event name',
),
style: Theme.of(context).textTheme.display1,
),
new TextField(
labelText: 'Location',
decoration: const InputDecoration(
labelText: 'Location',
),
style: Theme.of(context).textTheme.display1.copyWith(fontSize: 20.0),
),
new _DateTimePicker(
......@@ -170,9 +176,11 @@ class _DateAndTimePickerDemoState extends State<DateAndTimePickerDemo> {
});
},
),
new InputContainer(
labelText: 'Activity',
hintText: 'Choose an activity',
new InputDecorator(
decoration: const InputDecoration(
labelText: 'Activity',
hintText: 'Choose an activity',
),
isEmpty: _activity == null,
child: new DropdownButton<String>(
value: _activity,
......
......@@ -148,10 +148,11 @@ class DemoItem<T> {
this.hint,
this.builder,
this.valueToString
});
}) : textController = new TextEditingController(text: valueToString(value));
final String name;
final String hint;
final TextEditingController textController;
final DemoItemBodyBuilder<T> builder;
final ValueToString<T> valueToString;
T value;
......@@ -205,18 +206,20 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
onCancel: () { Form.of(context).reset(); close(); },
child: new Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: new TextField(
hintText: item.hint,
labelText: item.name,
initialValue: new InputValue(text: item.value),
onSaved: (InputValue val) { item.value = val.text; },
child: new TextFormField(
controller: item.textController,
decoration: new InputDecoration(
hintText: item.hint,
labelText: item.name,
),
onSaved: (String value) { item.value = value; },
),
),
);
}
)
},
),
);
}
},
),
new DemoItem<_Location>(
name: 'Location',
......@@ -229,8 +232,6 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
item.isExpanded = false;
});
}
return new Form(
child: new Builder(
builder: (BuildContext context) {
......
......@@ -27,6 +27,6 @@ export 'slider_demo.dart';
export 'snack_bar_demo.dart';
export 'tabs_demo.dart';
export 'tabs_fab_demo.dart';
export 'text_field_demo.dart';
export 'text_form_field_demo.dart';
export 'tooltip_demo.dart';
export 'two_level_list_demo.dart';
......@@ -6,13 +6,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
class TextFieldDemo extends StatefulWidget {
TextFieldDemo({ Key key }) : super(key: key);
class TextFormFieldDemo extends StatefulWidget {
TextFormFieldDemo({ Key key }) : super(key: key);
static const String routeName = '/material/text-field';
static const String routeName = '/material/text-form-field';
@override
TextFieldDemoState createState() => new TextFieldDemoState();
TextFormFieldDemoState createState() => new TextFormFieldDemoState();
}
class PersonData {
......@@ -21,7 +21,7 @@ class PersonData {
String password = '';
}
class TextFieldDemoState extends State<TextFieldDemo> {
class TextFormFieldDemoState extends State<TextFormFieldDemo> {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
PersonData person = new PersonData();
......@@ -35,7 +35,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
bool _autovalidate = false;
bool _formWasEdited = false;
final GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
final GlobalKey<FormFieldState<InputValue>> _passwordFieldKey = new GlobalKey<FormFieldState<InputValue>>();
final GlobalKey<FormFieldState<String>> _passwordFieldKey = new GlobalKey<FormFieldState<String>>();
void _handleSubmitted() {
final FormState form = _formKey.currentState;
if (!form.validate()) {
......@@ -47,30 +47,30 @@ class TextFieldDemoState extends State<TextFieldDemo> {
}
}
String _validateName(InputValue value) {
String _validateName(String value) {
_formWasEdited = true;
if (value.text.isEmpty)
if (value.isEmpty)
return 'Name is required.';
final RegExp nameExp = new RegExp(r'^[A-za-z ]+$');
if (!nameExp.hasMatch(value.text))
if (!nameExp.hasMatch(value))
return 'Please enter only alphabetical characters.';
return null;
}
String _validatePhoneNumber(InputValue value) {
String _validatePhoneNumber(String value) {
_formWasEdited = true;
final RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$');
if (!phoneExp.hasMatch(value.text))
if (!phoneExp.hasMatch(value))
return '###-###-#### - Please enter a valid phone number.';
return null;
}
String _validatePassword(InputValue value) {
String _validatePassword(String value) {
_formWasEdited = true;
final FormFieldState<InputValue> passwordField = _passwordFieldKey.currentState;
if (passwordField.value == null || passwordField.value.text.isEmpty)
final FormFieldState<String> passwordField = _passwordFieldKey.currentState;
if (passwordField.value == null || passwordField.value.isEmpty)
return 'Please choose a password.';
if (passwordField.value.text != value.text)
if (passwordField.value != value)
return 'Passwords don\'t match';
return null;
}
......@@ -104,7 +104,7 @@ class TextFieldDemoState extends State<TextFieldDemo> {
return new Scaffold(
key: _scaffoldKey,
appBar: new AppBar(
title: new Text('Text fields')
title: new Text('Text fields'),
),
body: new Form(
key: _formKey,
......@@ -113,48 +113,58 @@ class TextFieldDemoState extends State<TextFieldDemo> {
child: new ListView(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: <Widget>[
new TextField(
icon: new Icon(Icons.person),
hintText: 'What do people call you?',
labelText: 'Name *',
onSaved: (InputValue val) { person.name = val.text; },
new TextFormField(
decoration: const InputDecoration(
icon: const Icon(Icons.person),
hintText: 'What do people call you?',
labelText: 'Name *',
),
onSaved: (String value) { person.name = value; },
validator: _validateName,
),
new TextField(
icon: new Icon(Icons.phone),
hintText: 'Where can we reach you?',
labelText: 'Phone Number *',
new TextFormField(
decoration: const InputDecoration(
icon: const Icon(Icons.phone),
hintText: 'Where can we reach you?',
labelText: 'Phone Number *',
),
keyboardType: TextInputType.phone,
onSaved: (InputValue val) { person.phoneNumber = val.text; },
onSaved: (String value) { person.phoneNumber = value; },
validator: _validatePhoneNumber,
),
new TextField(
hintText: 'Tell us about yourself',
labelText: 'Life story',
new TextFormField(
decoration: const InputDecoration(
hintText: 'Tell us about yourself',
labelText: 'Life story',
),
maxLines: 3,
),
new Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
new Expanded(
child: new TextField(
child: new TextFormField(
key: _passwordFieldKey,
hintText: 'How do you log in?',
labelText: 'New Password *',
decoration: const InputDecoration(
hintText: 'How do you log in?',
labelText: 'New Password *',
),
obscureText: true,
onSaved: (InputValue val) { person.password = val.text; }
)
onSaved: (String value) { person.password = value; },
),
),
const SizedBox(width: 16.0),
new Expanded(
child: new TextField(
hintText: 'How do you log in?',
labelText: 'Re-type Password *',
child: new TextFormField(
decoration: const InputDecoration(
hintText: 'How do you log in?',
labelText: 'Re-type Password *',
),
obscureText: true,
validator: _validatePassword,
)
)
]
),
),
],
),
new Container(
padding: const EdgeInsets.all(20.0),
......@@ -168,9 +178,9 @@ class TextFieldDemoState extends State<TextFieldDemo> {
padding: const EdgeInsets.only(top: 20.0),
child: new Text('* indicates required field', style: Theme.of(context).textTheme.caption),
),
]
],
)
)
),
);
}
}
......@@ -261,8 +261,8 @@ final List<GalleryItem> kAllGalleryItems = <GalleryItem>[
title: 'Text fields',
subtitle: 'Single line of editable text and numbers',
category: 'Material Components',
routeName: TextFieldDemo.routeName,
buildRoute: (BuildContext context) => new TextFieldDemo(),
routeName: TextFormFieldDemo.routeName,
buildRoute: (BuildContext context) => new TextFormFieldDemo(),
),
new GalleryItem(
title: 'Tooltips',
......
......@@ -29,22 +29,22 @@ class _NotImplementedDialog extends StatelessWidget {
children: <Widget>[
new Icon(
Icons.dvr,
size: 18.0
size: 18.0,
),
new Container(
width: 8.0
width: 8.0,
),
new Text('DUMP APP TO CONSOLE'),
]
)
],
),
),
new FlatButton(
onPressed: () {
Navigator.pop(context, false);
},
child: new Text('OH WELL')
)
]
child: new Text('OH WELL'),
),
],
);
}
}
......@@ -65,7 +65,7 @@ class StockHomeState extends State<StockHome> {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
bool _isSearching = false;
InputValue _searchQuery = InputValue.empty;
final TextEditingController _searchQuery = new TextEditingController();
bool _autorefresh = false;
void _handleSearchBegin() {
......@@ -73,9 +73,9 @@ class StockHomeState extends State<StockHome> {
onRemove: () {
setState(() {
_isSearching = false;
_searchQuery = InputValue.empty;
_searchQuery.clear();
});
}
},
));
setState(() {
_isSearching = true;
......@@ -86,12 +86,6 @@ class StockHomeState extends State<StockHome> {
Navigator.pop(context);
}
void _handleSearchQueryChanged(InputValue query) {
setState(() {
_searchQuery = query;
});
}
void _handleStockModeChange(StockMode value) {
if (config.updater != null)
config.updater(config.configuration.copyWith(stockMode: value));
......@@ -155,7 +149,7 @@ class StockHomeState extends State<StockHome> {
trailing: new Radio<StockMode>(
value: StockMode.optimistic,
groupValue: config.configuration.stockMode,
onChanged: _handleStockModeChange
onChanged: _handleStockModeChange,
),
onTap: () {
_handleStockModeChange(StockMode.optimistic);
......@@ -167,7 +161,7 @@ class StockHomeState extends State<StockHome> {
trailing: new Radio<StockMode>(
value: StockMode.pessimistic,
groupValue: config.configuration.stockMode,
onChanged: _handleStockModeChange
onChanged: _handleStockModeChange,
),
onTap: () {
_handleStockModeChange(StockMode.pessimistic);
......@@ -184,8 +178,8 @@ class StockHomeState extends State<StockHome> {
title: new Text('About'),
onTap: _handleShowAbout,
),
]
)
],
),
);
}
......@@ -205,7 +199,7 @@ class StockHomeState extends State<StockHome> {
new IconButton(
icon: new Icon(Icons.search),
onPressed: _handleSearchBegin,
tooltip: 'Search'
tooltip: 'Search',
),
new PopupMenuButton<_StockMenuItem>(
onSelected: (_StockMenuItem value) { _handleStockMenu(context, value); },
......@@ -213,29 +207,29 @@ class StockHomeState extends State<StockHome> {
new CheckedPopupMenuItem<_StockMenuItem>(
value: _StockMenuItem.autorefresh,
checked: _autorefresh,
child: new Text('Autorefresh')
child: new Text('Autorefresh'),
),
new PopupMenuItem<_StockMenuItem>(
value: _StockMenuItem.refresh,
child: new Text('Refresh')
child: new Text('Refresh'),
),
new PopupMenuItem<_StockMenuItem>(
value: _StockMenuItem.speedUp,
child: new Text('Increase animation speed')
child: new Text('Increase animation speed'),
),
new PopupMenuItem<_StockMenuItem>(
value: _StockMenuItem.speedDown,
child: new Text('Decrease animation speed')
)
]
)
child: new Text('Decrease animation speed'),
),
],
),
],
bottom: new TabBar(
tabs: <Widget>[
new Tab(text: StockStrings.of(context).market()),
new Tab(text: StockStrings.of(context).portfolio()),
]
)
],
),
);
}
......@@ -262,8 +256,8 @@ class StockHomeState extends State<StockHome> {
label: "BUY MORE",
onPressed: () {
_buyStock(stock);
}
)
},
),
));
}
......@@ -276,14 +270,14 @@ class StockHomeState extends State<StockHome> {
},
onShow: (Stock stock) {
_scaffoldKey.currentState.showBottomSheet<Null>((BuildContext context) => new StockSymbolBottomSheet(stock: stock));
}
},
);
}
Widget _buildStockTab(BuildContext context, StockHomeTab tab, List<String> stockSymbols) {
return new Container(
key: new ValueKey<StockHomeTab>(tab),
child: _buildStockList(context, _filterBySearchQuery(_getStockList(stockSymbols)).toList(), tab)
child: _buildStockList(context, _filterBySearchQuery(_getStockList(stockSymbols)).toList(), tab),
);
}
......@@ -296,21 +290,23 @@ class StockHomeState extends State<StockHome> {
icon: new Icon(Icons.arrow_back),
color: Theme.of(context).accentColor,
onPressed: _handleSearchEnd,
tooltip: 'Back'
tooltip: 'Back',
),
title: new TextField(
controller: _searchQuery,
autofocus: true,
hintText: 'Search stocks',
onChanged: _handleSearchQueryChanged
decoration: const InputDecoration(
hintText: 'Search stocks',
),
),
backgroundColor: Theme.of(context).canvasColor
backgroundColor: Theme.of(context).canvasColor,
);
}
void _handleCreateCompany() {
showModalBottomSheet<Null>(
context: context,
builder: (BuildContext context) => new _CreateCompanySheet()
builder: (BuildContext context) => new _CreateCompanySheet(),
);
}
......@@ -319,7 +315,7 @@ class StockHomeState extends State<StockHome> {
tooltip: 'Create company',
child: new Icon(Icons.add),
backgroundColor: Colors.redAccent,
onPressed: _handleCreateCompany
onPressed: _handleCreateCompany,
);
}
......@@ -336,9 +332,9 @@ class StockHomeState extends State<StockHome> {
children: <Widget>[
_buildStockTab(context, StockHomeTab.market, config.symbols),
_buildStockTab(context, StockHomeTab.portfolio, portfolioSymbols),
]
)
)
],
),
),
);
}
}
......@@ -351,9 +347,11 @@ class _CreateCompanySheet extends StatelessWidget {
children: <Widget>[
new TextField(
autofocus: true,
hintText: 'Company Name',
decoration: const InputDecoration(
hintText: 'Company Name',
),
),
]
],
);
}
}
......@@ -52,7 +52,7 @@ export 'src/material/image_icon.dart';
export 'src/material/ink_highlight.dart';
export 'src/material/ink_splash.dart';
export 'src/material/ink_well.dart';
export 'src/material/input.dart';
export 'src/material/input_decorator.dart';
export 'src/material/list_tile.dart';
export 'src/material/material.dart';
export 'src/material/mergeable_material.dart';
......@@ -72,6 +72,9 @@ export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/tab_controller.dart';
export 'src/material/tabs.dart';
export 'src/material/text_field.dart';
export 'src/material/text_form_field.dart';
export 'src/material/text_selection.dart';
export 'src/material/theme.dart';
export 'src/material/theme_data.dart';
export 'src/material/time_picker.dart';
......
......@@ -158,6 +158,19 @@ class _MergingListenable extends ChangeNotifier {
child?.removeListener(notifyListeners);
super.dispose();
}
@override
String toString() {
final StringBuffer buffer = new StringBuffer();
buffer.write('_MergingListenable([');
for (int i = 0; i < _children.length; ++i) {
buffer.write(_children[i].toString());
if (i < _children.length - 1)
buffer.write(', ');
}
buffer.write('])');
return buffer.toString();
}
}
/// A [ChangeNotifier] that holds a single value.
......
......@@ -462,7 +462,7 @@ class DropdownButton<T> extends StatefulWidget {
/// By default this button's height is the same as its menu items' heights.
/// If isDense is true, the button's height is reduced by about half. This
/// can be useful when the button is embedded in a container that adds
/// its own decorations, like [InputContainer].
/// its own decorations, like [InputDecorator].
final bool isDense;
@override
......
This diff is collapsed.
This diff is collapsed.
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'input_decorator.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 undecorated text input field.
///
/// If you want decorations as specified in the Material spec (most likely),
/// use [Input] instead.
///
/// 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.
/// A Material Design text field.
///
/// A text field lets the user enter text, either with hardware keyboard or with
/// an onscreen keyboard.
///
/// The text field calls the [onChanged] callback whenever the user changes the
/// text in the field. If the user indicates that they are done typing in the
/// field (e.g., by pressing a button on the soft keyboard), the text field
/// calls the [onSubmitted] callback.
///
/// To control the text that is displayed in the text field, use the
/// [controller]. For example, to set the initial value of the text field, use
/// a [controller] that already contains some text. The [controller] can also
/// control the selection and composing region (and to observe changes to the
/// text, selection, and composing region).
///
/// By default, a text field has a [decoration] that draws a divider below the
/// text field. You can use the [decoration] property to control the decoration,
/// for example by adding a label or an icon. If you set the [decoration]
/// property to null, the decoration will be removed entirely, including the
/// extra padding introduced by the decoration to save space for the labels.
///
/// If [decoration] is non-null (which is the default), the text field requires
/// one of its ancestors to be a [Material] widget.
///
/// To integrate the [TextField] into a [Form] with other [FormField] widgets,
/// consider using [TextFormField].
///
/// See also:
///
/// * <https://material.google.com/components/text-fields.html>
/// * [TextFormField], which integrates with the [Form] widget.
/// * [InputDecorator], which shows the labels and other visual elements that
/// surround the actual text editing widget.
/// * [EditableText], which is the raw text editing control at the heart of a
/// [TextField]. (The [EditableText] widget is rarely used directly unless
/// you are implementing an entirely different design language, such as
/// Cupertino.)
class TextField extends StatefulWidget {
/// Creates a Material Design text field.
///
/// If [decoration] is non-null (which is the default), the text field requires
/// one of its ancestors to be a [Material] widget.
///
/// To remove the decoration entirely (including the extra padding introduced
/// by the decoration to save space for the labels), set the [decoration] to
/// null.
TextField({
Key key,
this.controller,
this.focusNode,
this.decoration: const InputDecoration(),
this.keyboardType: TextInputType.text,
this.style,
this.autofocus: false,
this.obscureText: false,
this.maxLines: 1,
this.onChanged,
this.onSubmitted,
}) : super(key: key);
/// Controls the text being edited.
///
/// If null, this widget will creates its own [TextEditingController].
final TextEditingController controller;
/// Controls whether this widget has keyboard focus.
///
/// If null, this widget will create its own [FocusNode].
final FocusNode focusNode;
/// The decoration to show around the text field.
///
/// By default, draws a horizontal line under the input field but can be
/// configured to show an icon, label, hint text, and error text.
///
/// Set this field to null to remove the decoration entirely (including the
/// extra padding introduced by the decoration to save space for the labels).
final InputDecoration decoration;
/// The type of keyboard to use for editing the text.
final TextInputType keyboardType;
/// The style to use for the text being edited.
///
/// This text style is also used as the base style for the [decoration].
///
/// If null, defaults to a text style from the current [Theme].
final TextStyle style;
/// Whether this input field should focus itself if nothing else is already
/// focused.
///
/// If true, the keyboard will open as soon as this input obtains focus.
/// Otherwise, the keyboard is only shown after the user taps the text field.
///
/// Defaults to false.
// See https://github.com/flutter/flutter/issues/7035 for the rationale for this
// keyboard behavior.
final bool autofocus;
/// 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
/// U+2022 BULLET characters (•).
///
/// Defaults to false.
final bool obscureText;
/// The maximum number of lines for the text to span, wrapping if necessary.
///
/// If this is 1 (the default), the text will not wrap, but will scroll
/// horizontally instead.
final int maxLines;
/// Called when the text being edited changes.
final ValueChanged<String> onChanged;
/// Called when the user indicates that they are done editing the text in the
/// field.
final ValueChanged<String> onSubmitted;
@override
_TextFieldState createState() => new _TextFieldState();
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
if (controller != null)
description.add('controller: $controller');
if (focusNode != null)
description.add('focusNode: $focusNode');
description.add('decoration: $decoration');
if (keyboardType != TextInputType.text)
description.add('keyboardType: $keyboardType');
if (style != null)
description.add('style: $style');
if (autofocus)
description.add('autofocus: $autofocus');
if (obscureText)
description.add('obscureText: $obscureText');
if (maxLines != 1)
description.add('maxLines: $maxLines');
}
}
class _TextFieldState extends State<TextField> {
final GlobalKey<EditableTextState> _editableTextKey = new GlobalKey<EditableTextState>();
TextEditingController _controller;
TextEditingController get _effectiveController => config.controller ?? _controller;
FocusNode _focusNode;
FocusNode get _effectiveFocusNode => config.focusNode ?? (_focusNode ??= new FocusNode());
@override
void initState() {
super.initState();
if (config.controller == null)
_controller = new TextEditingController();
}
@override
void didUpdateConfig(TextField oldConfig) {
if (config.controller == null && oldConfig.controller != null)
_controller == new TextEditingController.fromValue(oldConfig.controller.value);
else if (config.controller != null && oldConfig.controller == null)
_controller = null;
}
@override
void dispose() {
_focusNode?.dispose();
super.dispose();
}
void _requestKeyboard() {
_editableTextKey.currentState?.requestKeyboard();
}
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final TextStyle style = config.style ?? themeData.textTheme.subhead;
final TextEditingController controller = _effectiveController;
final FocusNode focusNode = _effectiveFocusNode;
Widget child = new RepaintBoundary(
child: new EditableText(
key: _editableTextKey,
controller: controller,
focusNode: focusNode,
keyboardType: config.keyboardType,
style: style,
autofocus: config.autofocus,
obscureText: config.obscureText,
maxLines: config.maxLines,
cursorColor: themeData.textSelectionColor,
selectionColor: themeData.textSelectionColor,
selectionControls: materialTextSelectionControls,
onChanged: config.onChanged,
onSubmitted: config.onSubmitted,
),
);
if (config.decoration != null) {
child = new AnimatedBuilder(
animation: new Listenable.merge(<Listenable>[ focusNode, controller ]),
builder: (BuildContext context, Widget child) {
return new InputDecorator(
decoration: config.decoration,
baseStyle: config.style,
isFocused: focusNode.hasFocus,
isEmpty: controller.value.text.isEmpty,
child: child,
);
},
child: child,
);
}
return new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _requestKeyboard,
child: child,
);
}
}
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'input_decorator.dart';
import 'text_field.dart';
/// A [FormField] that contains a [TextField].
///
/// This is a convenience widget that simply wraps a [TextField] widget in a
/// [FormField].
///
/// A [Form] ancestor is not required. The [Form] simply makes it easier to
/// save, reset, or validate multiple fields at once. To use without a [Form],
/// pass a [GlobalKey] to the constructor and use [GlobalKey.currentState] to
/// save or reset the form field.
///
/// See also:
///
/// * <https://material.google.com/components/text-fields.html>
/// * [TextField], which is the underlying text field without the [Form]
/// integration.
/// * [InputDecorator], which shows the labels and other visual elements that
/// surround the actual text editing widget.
class TextFormField extends FormField<String> {
TextFormField({
Key key,
TextEditingController controller,
FocusNode focusNode,
InputDecoration decoration: const InputDecoration(),
TextInputType keyboardType: TextInputType.text,
TextStyle style,
bool autofocus: false,
bool obscureText: false,
int maxLines: 1,
FormFieldSetter<String> onSaved,
FormFieldValidator<String> validator,
}) : super(
key: key,
initialValue: controller != null ? controller.value.text : '',
onSaved: onSaved,
validator: validator,
builder: (FormFieldState<String> field) {
return new TextField(
controller: controller,
focusNode: focusNode,
decoration: decoration.copyWith(errorText: field.errorText),
keyboardType: keyboardType,
style: style,
autofocus: autofocus,
obscureText: obscureText,
maxLines: maxLines,
onChanged: (String value) {
field.onChanged(value);
},
);
},
);
}
......@@ -20,7 +20,7 @@ class _TextSelectionToolbar extends StatelessWidget {
_TextSelectionToolbar(this.delegate, {Key key}) : super(key: key);
final TextSelectionDelegate delegate;
InputValue get value => delegate.inputValue;
TextEditingValue get value => delegate.textEditingValue;
@override
Widget build(BuildContext context) {
......@@ -51,7 +51,7 @@ class _TextSelectionToolbar extends StatelessWidget {
void _handleCut() {
Clipboard.setData(new ClipboardData(text: value.selection.textInside(value.text)));
delegate.inputValue = new InputValue(
delegate.textEditingValue = new TextEditingValue(
text: value.selection.textBefore(value.text) + value.selection.textAfter(value.text),
selection: new TextSelection.collapsed(offset: value.selection.start)
);
......@@ -60,7 +60,7 @@ class _TextSelectionToolbar extends StatelessWidget {
void _handleCopy() {
Clipboard.setData(new ClipboardData(text: value.selection.textInside(value.text)));
delegate.inputValue = new InputValue(
delegate.textEditingValue = new TextEditingValue(
text: value.text,
selection: new TextSelection.collapsed(offset: value.selection.end)
);
......@@ -68,10 +68,10 @@ class _TextSelectionToolbar extends StatelessWidget {
}
Future<Null> _handlePaste() async {
final InputValue value = this.value; // Snapshot the input before using `await`.
final TextEditingValue value = this.value; // Snapshot the input before using `await`.
final ClipboardData data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
delegate.inputValue = new InputValue(
delegate.textEditingValue = new TextEditingValue(
text: value.selection.textBefore(value.text) + data.text + value.selection.textAfter(value.text),
selection: new TextSelection.collapsed(offset: value.selection.start + data.text.length)
);
......@@ -80,7 +80,7 @@ class _TextSelectionToolbar extends StatelessWidget {
}
void _handleSelectAll() {
delegate.inputValue = new InputValue(
delegate.textEditingValue = new TextEditingValue(
text: value.text,
selection: new TextSelection(baseOffset: 0, extentOffset: value.text.length)
);
......@@ -196,4 +196,4 @@ class _MaterialTextSelectionControls extends TextSelectionControls {
}
}
final _MaterialTextSelectionControls materialTextSelectionControls = new _MaterialTextSelectionControls();
final TextSelectionControls materialTextSelectionControls = new _MaterialTextSelectionControls();
......@@ -44,7 +44,7 @@ class MethodCall {
final dynamic arguments;
@override
bool operator== (dynamic other) {
bool operator == (dynamic other) {
if (identical(this, other))
return true;
if (runtimeType != other.runtimeType)
......
......@@ -43,22 +43,22 @@ enum TextSelectionHandleType {
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { start, end }
/// Signature for reporting changes to the selection component of an
/// [InputValue] for the purposes of a [TextSelectionOverlay]. The [caretRect]
/// argument gives the location of the caret in the coordinate space of the
/// [RenderBox] given by the [TextSelectionOverlay.renderObject].
/// Signature for reporting changes to the selection component of a
/// [TextEditingValue] for the purposes of a [TextSelectionOverlay]. The
/// [caretRect] argument gives the location of the caret in the coordinate space
/// of the [RenderBox] given by the [TextSelectionOverlay.renderObject].
///
/// Used by [TextSelectionOverlay.onSelectionOverlayChanged].
typedef void TextSelectionOverlayChanged(InputValue value, Rect caretRect);
typedef void TextSelectionOverlayChanged(TextEditingValue value, Rect caretRect);
/// An interface for manipulating the selection, to be used by the implementor
/// of the toolbar widget.
abstract class TextSelectionDelegate {
/// Gets the current text input.
InputValue get inputValue;
TextEditingValue get textEditingValue;
/// Sets the current text input (replaces the whole line).
set inputValue(InputValue value);
set textEditingValue(TextEditingValue value);
/// Hides the text selection toolbar.
void hideToolbar();
......@@ -89,13 +89,14 @@ class TextSelectionOverlay implements TextSelectionDelegate {
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
TextSelectionOverlay({
InputValue input,
@required TextEditingValue value,
@required this.context,
this.debugRequiredFor,
this.renderObject,
this.onSelectionOverlayChanged,
this.selectionControls,
}): _input = input {
}): _value = value {
assert(value != null);
assert(context != null);
final OverlayState overlay = Overlay.of(context);
assert(overlay != null);
......@@ -133,7 +134,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
Animation<double> get _handleOpacity => _handleController.view;
Animation<double> get _toolbarOpacity => _toolbarController.view;
InputValue _input;
TextEditingValue _value;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
......@@ -142,7 +143,7 @@ class TextSelectionOverlay implements TextSelectionDelegate {
/// A copy/paste toolbar.
OverlayEntry _toolbar;
TextSelection get _selection => _input.selection;
TextSelection get _selection => _value.selection;
/// Shows the handles by inserting them into the [context]'s overlay.
void showHandles() {
......@@ -172,10 +173,10 @@ class TextSelectionOverlay implements TextSelectionDelegate {
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(InputValue newInput) {
if (_input == newInput)
void update(TextEditingValue newValue) {
if (_value == newValue)
return;
_input = newInput;
_value = newValue;
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback(_markNeedsBuild);
} else {
......@@ -259,13 +260,13 @@ class TextSelectionOverlay implements TextSelectionDelegate {
caretRect = renderObject.getLocalRectForCaret(newSelection.extent);
break;
}
update(_input.copyWith(selection: newSelection, composing: TextRange.empty));
update(_value.copyWith(selection: newSelection, composing: TextRange.empty));
if (onSelectionOverlayChanged != null)
onSelectionOverlayChanged(_input, caretRect);
onSelectionOverlayChanged(_value, caretRect);
}
void _handleSelectionHandleTapped() {
if (inputValue.selection.isCollapsed) {
if (_value.selection.isCollapsed) {
if (_toolbar != null) {
_toolbar?.remove();
_toolbar = null;
......@@ -276,14 +277,14 @@ class TextSelectionOverlay implements TextSelectionDelegate {
}
@override
InputValue get inputValue => _input;
TextEditingValue get textEditingValue => _value;
@override
set inputValue(InputValue value) {
update(value);
set textEditingValue(TextEditingValue newValue) {
update(newValue);
if (onSelectionOverlayChanged != null) {
final Rect caretRect = renderObject.getLocalRectForCaret(value.selection.extent);
onSelectionOverlayChanged(value, caretRect);
final Rect caretRect = renderObject.getLocalRectForCaret(newValue.selection.extent);
onSelectionOverlayChanged(newValue, caretRect);
}
}
......
......@@ -43,9 +43,9 @@ void main() {
await tester.pumpWidget(new MaterialApp(
home: new Material(
child: new Center(
child: new Input(focusNode: focusNode, autofocus: true)
)
)
child: new TextField(focusNode: focusNode, autofocus: true),
),
),
));
expect(focusNode.hasFocus, isTrue);
......
......@@ -15,11 +15,11 @@ void main() {
child: new Material(
child: new Form(
key: formKey,
child: new TextField(
onSaved: (InputValue value) { fieldValue = value.text; },
child: new TextFormField(
onSaved: (String value) { fieldValue = value; },
),
)
)
),
),
);
}
......@@ -47,10 +47,10 @@ void main() {
child: new Material(
child: new Form(
child: new TextField(
onChanged: (InputValue value) { fieldValue = value.text; },
onChanged: (String value) { fieldValue = value; },
),
)
)
),
),
);
}
......@@ -71,8 +71,7 @@ void main() {
testWidgets('Validator sets the error text only when validate is called', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = new GlobalKey<FormState>();
final GlobalKey inputKey = new GlobalKey();
String errorText(InputValue input) => input.text + '/error';
String errorText(String value) => value + '/error';
Widget builder(bool autovalidate) {
return new Center(
......@@ -80,12 +79,11 @@ void main() {
child: new Form(
key: formKey,
autovalidate: autovalidate,
child: new TextField(
key: inputKey,
child: new TextFormField(
validator: errorText,
),
)
)
),
),
);
}
......@@ -99,10 +97,10 @@ void main() {
await tester.pumpWidget(builder(false));
// We have to manually validate if we're not autovalidating.
expect(find.text(errorText(new InputValue(text: testValue))), findsNothing);
expect(find.text(errorText(testValue)), findsNothing);
formKey.currentState.validate();
await tester.pump();
expect(find.text(errorText(new InputValue(text: testValue))), findsOneWidget);
expect(find.text(errorText(testValue)), findsOneWidget);
// Try again with autovalidation. Should validate immediately.
formKey.currentState.reset();
......@@ -110,18 +108,18 @@ void main() {
await tester.idle();
await tester.pumpWidget(builder(true));
expect(find.text(errorText(new InputValue(text: testValue))), findsOneWidget);
expect(find.text(errorText(testValue)), findsOneWidget);
}
await checkErrorText('Test');
await checkErrorText('');
});
testWidgets('Multiple Inputs communicate', (WidgetTester tester) async {
testWidgets('Multiple TextFormFields communicate', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = new GlobalKey<FormState>();
final GlobalKey<FormFieldState<InputValue>> fieldKey = new GlobalKey<FormFieldState<InputValue>>();
final GlobalKey<FormFieldState<String>> fieldKey = new GlobalKey<FormFieldState<String>>();
// Input 2's validator depends on a input 1's value.
String errorText(InputValue input) => fieldKey.currentState.value?.text.toString() + '/error';
String errorText(String input) => fieldKey.currentState.value?.toString() + '/error';
Widget builder() {
return new Center(
......@@ -131,10 +129,10 @@ void main() {
autovalidate: true,
child: new ListView(
children: <Widget>[
new TextField(
new TextFormField(
key: fieldKey,
),
new TextField(
new TextFormField(
validator: errorText,
),
],
......@@ -162,18 +160,19 @@ void main() {
testWidgets('Provide initial value to input', (WidgetTester tester) async {
final String initialValue = 'hello';
final GlobalKey<FormFieldState<InputValue>> inputKey = new GlobalKey<FormFieldState<InputValue>>();
final TextEditingController controller = new TextEditingController(text: initialValue);
final GlobalKey<FormFieldState<String>> inputKey = new GlobalKey<FormFieldState<String>>();
Widget builder() {
return new Center(
child: new Material(
child: new Form(
child: new TextField(
child: new TextFormField(
key: inputKey,
initialValue: new InputValue(text: initialValue),
controller: controller,
),
)
)
),
),
);
}
......@@ -186,20 +185,19 @@ void main() {
// initial value should also be visible in the raw input line
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.config.value.text, equals(initialValue));
expect(editableText.config.controller.text, equals(initialValue));
// sanity check, make sure we can still edit the text and everything updates
expect(inputKey.currentState.value.text, equals(initialValue));
expect(inputKey.currentState.value, equals(initialValue));
await tester.enterText(find.byType(EditableText), 'world');
await tester.idle();
await tester.pump();
expect(inputKey.currentState.value.text, equals('world'));
expect(editableText.config.value.text, equals('world'));
expect(inputKey.currentState.value, equals('world'));
expect(editableText.config.controller.text, equals('world'));
});
testWidgets('No crash when a FormField is removed from the tree', (WidgetTester tester) async {
testWidgets('No crash when a TextFormField is removed from the tree', (WidgetTester tester) async {
final GlobalKey<FormState> formKey = new GlobalKey<FormState>();
final GlobalKey fieldKey = new GlobalKey();
String fieldValue;
Widget builder(bool remove) {
......@@ -207,14 +205,13 @@ void main() {
child: new Material(
child: new Form(
key: formKey,
child: remove ? new Container() : new TextField(
key: fieldKey,
child: remove ? new Container() : new TextFormField(
autofocus: true,
onSaved: (InputValue value) { fieldValue = value.text; },
validator: (InputValue value) { return value.text.isEmpty ? null : 'yes'; }
onSaved: (String value) { fieldValue = value; },
validator: (String value) { return value.isEmpty ? null : 'yes'; }
),
)
)
),
),
);
}
......
......@@ -321,7 +321,7 @@ class FlutterDriver {
return null;
}
/// Sets the text value of the `Input` widget located by [finder].
/// Sets the text value of the [TextField] widget located by [finder].
///
/// This command invokes the `onChanged` handler of the `Input` widget with
/// the provided [text].
......@@ -330,10 +330,10 @@ class FlutterDriver {
return null;
}
/// Submits the current text value of the `Input` widget located by [finder].
/// Submits the current text value of the [TextField] widget located by [finder].
///
/// This command invokes the `onSubmitted` handler of the `Input` widget and
/// the returns the submitted text value.
/// This command invokes the `onSubmitted` handler of the [TextField] widget
/// and the returns the submitted text value.
Future<String> submitInputText(SerializableFinder finder, {Duration timeout}) async {
final Map<String, dynamic> json = await _sendCommand(new SubmitInputText(finder, timeout: timeout));
return json['text'];
......
......@@ -10,6 +10,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RendererBinding;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -262,20 +263,29 @@ class FlutterDriverExtension {
return new ScrollResult();
}
Finder _findEditableText(SerializableFinder finder) {
return find.descendant(of: _createFinder(finder), matching: find.byType(EditableText));
}
EditableTextState _getEditableTextState(Finder finder) {
final StatefulElement element = finder.evaluate().single;
return element.state;
}
Future<SetInputTextResult> _setInputText(Command command) async {
final SetInputText setInputTextCommand = command;
final Finder target = await _waitForElement(_createFinder(setInputTextCommand.finder));
final Input input = target.evaluate().single.widget;
input.onChanged(new InputValue(text: setInputTextCommand.text));
final Finder target = await _waitForElement(_findEditableText(setInputTextCommand.finder));
final EditableTextState editable = _getEditableTextState(target);
editable.updateEditingValue(new TextEditingValue(text: setInputTextCommand.text));
return new SetInputTextResult();
}
Future<SubmitInputTextResult> _submitInputText(Command command) async {
final SubmitInputText submitInputTextCommand = command;
final Finder target = await _waitForElement(_createFinder(submitInputTextCommand.finder));
final Input input = target.evaluate().single.widget;
input.onSubmitted(input.value);
return new SubmitInputTextResult(input.value.text);
final Finder target = await _waitForElement(_findEditableText(submitInputTextCommand.finder));
final EditableTextState editable = _getEditableTextState(target);
editable.performAction(TextInputAction.done);
return new SubmitInputTextResult(editable.config.controller.value.text);
}
Future<GetTextResult> _getText(Command command) async {
......
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