From ae8994860e42c3eff67149282e93be47c07f9f93 Mon Sep 17 00:00:00 2001 From: Adam Barth Date: Sat, 1 Apr 2017 17:30:21 -0700 Subject: [PATCH] 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 --- dev/manual_tests/card_collection.dart | 12 +- .../material/date_and_time_picker_demo.dart | 24 +- .../demo/material/expansion_panels_demo.dart | 23 +- .../lib/demo/material/material.dart | 2 +- ...ld_demo.dart => text_form_field_demo.dart} | 94 ++- .../flutter_gallery/lib/gallery/item.dart | 4 +- examples/stocks/lib/stock_home.dart | 88 +- packages/flutter/lib/material.dart | 5 +- .../lib/src/foundation/change_notifier.dart | 13 + .../flutter/lib/src/material/dropdown.dart | 2 +- packages/flutter/lib/src/material/input.dart | 754 ------------------ .../lib/src/material/input_decorator.dart | 498 ++++++++++++ .../flutter/lib/src/material/text_field.dart | 268 +++++++ .../lib/src/material/text_form_field.dart | 62 ++ .../lib/src/material/text_selection.dart | 14 +- .../lib/src/services/message_codec.dart | 2 +- .../lib/src/widgets/editable_text.dart | 262 +++--- .../lib/src/widgets/text_selection.dart | 45 +- packages/flutter/test/material/app_test.dart | 6 +- .../text_field_test.dart} | 400 ++++------ packages/flutter/test/widgets/form_test.dart | 73 +- packages/flutter_driver/lib/src/driver.dart | 8 +- .../flutter_driver/lib/src/extension.dart | 24 +- 23 files changed, 1355 insertions(+), 1328 deletions(-) rename examples/flutter_gallery/lib/demo/material/{text_field_demo.dart => text_form_field_demo.dart} (62%) delete mode 100644 packages/flutter/lib/src/material/input.dart create mode 100644 packages/flutter/lib/src/material/input_decorator.dart create mode 100644 packages/flutter/lib/src/material/text_field.dart create mode 100644 packages/flutter/lib/src/material/text_form_field.dart rename packages/flutter/test/{widgets/input_test.dart => material/text_field_test.dart} (72%) diff --git a/dev/manual_tests/card_collection.dart b/dev/manual_tests/card_collection.dart index fd2e0e8f3e1..d0c303d9cce 100644 --- a/dev/manual_tests/card_collection.dart +++ b/dev/manual_tests/card_collection.dart @@ -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 { 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 { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center, children: [ - new Text(cardModel.inputValue.text, textAlign: _textAlign), + new Text(cardModel.textController.text, textAlign: _textAlign), ], ), ), diff --git a/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart b/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart index ae1144304e1..02590f892f0 100644 --- a/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/date_and_time_picker_demo.dart @@ -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 { padding: const EdgeInsets.all(16.0), children: [ 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 { }); }, ), - 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( value: _activity, diff --git a/examples/flutter_gallery/lib/demo/material/expansion_panels_demo.dart b/examples/flutter_gallery/lib/demo/material/expansion_panels_demo.dart index de211bb150c..c2b21f7f2b2 100644 --- a/examples/flutter_gallery/lib/demo/material/expansion_panels_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/expansion_panels_demo.dart @@ -148,10 +148,11 @@ class DemoItem { this.hint, this.builder, this.valueToString - }); + }) : textController = new TextEditingController(text: valueToString(value)); final String name; final String hint; + final TextEditingController textController; final DemoItemBodyBuilder builder; final ValueToString valueToString; T value; @@ -205,18 +206,20 @@ class _ExpansionPanelsDemoState extends State { 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 { item.isExpanded = false; }); } - - return new Form( child: new Builder( builder: (BuildContext context) { diff --git a/examples/flutter_gallery/lib/demo/material/material.dart b/examples/flutter_gallery/lib/demo/material/material.dart index 96078a83483..975e5523ae8 100644 --- a/examples/flutter_gallery/lib/demo/material/material.dart +++ b/examples/flutter_gallery/lib/demo/material/material.dart @@ -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'; diff --git a/examples/flutter_gallery/lib/demo/material/text_field_demo.dart b/examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart similarity index 62% rename from examples/flutter_gallery/lib/demo/material/text_field_demo.dart rename to examples/flutter_gallery/lib/demo/material/text_form_field_demo.dart index 093acd8b486..97afe67cdd0 100644 --- a/examples/flutter_gallery/lib/demo/material/text_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/text_form_field_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 { +class TextFormFieldDemoState extends State { final GlobalKey _scaffoldKey = new GlobalKey(); PersonData person = new PersonData(); @@ -35,7 +35,7 @@ class TextFieldDemoState extends State { bool _autovalidate = false; bool _formWasEdited = false; final GlobalKey _formKey = new GlobalKey(); - final GlobalKey> _passwordFieldKey = new GlobalKey>(); + final GlobalKey> _passwordFieldKey = new GlobalKey>(); void _handleSubmitted() { final FormState form = _formKey.currentState; if (!form.validate()) { @@ -47,30 +47,30 @@ class TextFieldDemoState extends State { } } - 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 passwordField = _passwordFieldKey.currentState; - if (passwordField.value == null || passwordField.value.text.isEmpty) + final FormFieldState 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 { 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 { child: new ListView( padding: const EdgeInsets.symmetric(horizontal: 16.0), children: [ - 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: [ 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 { padding: const EdgeInsets.only(top: 20.0), child: new Text('* indicates required field', style: Theme.of(context).textTheme.caption), ), - ] + ], ) - ) + ), ); } } diff --git a/examples/flutter_gallery/lib/gallery/item.dart b/examples/flutter_gallery/lib/gallery/item.dart index 4d049d40616..505141bb45e 100644 --- a/examples/flutter_gallery/lib/gallery/item.dart +++ b/examples/flutter_gallery/lib/gallery/item.dart @@ -261,8 +261,8 @@ final List kAllGalleryItems = [ 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', diff --git a/examples/stocks/lib/stock_home.dart b/examples/stocks/lib/stock_home.dart index b93d5400992..0ac3446cb77 100644 --- a/examples/stocks/lib/stock_home.dart +++ b/examples/stocks/lib/stock_home.dart @@ -29,22 +29,22 @@ class _NotImplementedDialog extends StatelessWidget { children: [ 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 { final GlobalKey _scaffoldKey = new GlobalKey(); 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 { onRemove: () { setState(() { _isSearching = false; - _searchQuery = InputValue.empty; + _searchQuery.clear(); }); - } + }, )); setState(() { _isSearching = true; @@ -86,12 +86,6 @@ class StockHomeState extends State { 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 { trailing: new Radio( value: StockMode.optimistic, groupValue: config.configuration.stockMode, - onChanged: _handleStockModeChange + onChanged: _handleStockModeChange, ), onTap: () { _handleStockModeChange(StockMode.optimistic); @@ -167,7 +161,7 @@ class StockHomeState extends State { trailing: new Radio( value: StockMode.pessimistic, groupValue: config.configuration.stockMode, - onChanged: _handleStockModeChange + onChanged: _handleStockModeChange, ), onTap: () { _handleStockModeChange(StockMode.pessimistic); @@ -184,8 +178,8 @@ class StockHomeState extends State { title: new Text('About'), onTap: _handleShowAbout, ), - ] - ) + ], + ), ); } @@ -205,7 +199,7 @@ class StockHomeState extends State { 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 { 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: [ new Tab(text: StockStrings.of(context).market()), new Tab(text: StockStrings.of(context).portfolio()), - ] - ) + ], + ), ); } @@ -262,8 +256,8 @@ class StockHomeState extends State { label: "BUY MORE", onPressed: () { _buyStock(stock); - } - ) + }, + ), )); } @@ -276,14 +270,14 @@ class StockHomeState extends State { }, onShow: (Stock stock) { _scaffoldKey.currentState.showBottomSheet((BuildContext context) => new StockSymbolBottomSheet(stock: stock)); - } + }, ); } Widget _buildStockTab(BuildContext context, StockHomeTab tab, List stockSymbols) { return new Container( key: new ValueKey(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 { 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( context: context, - builder: (BuildContext context) => new _CreateCompanySheet() + builder: (BuildContext context) => new _CreateCompanySheet(), ); } @@ -319,7 +315,7 @@ class StockHomeState extends State { tooltip: 'Create company', child: new Icon(Icons.add), backgroundColor: Colors.redAccent, - onPressed: _handleCreateCompany + onPressed: _handleCreateCompany, ); } @@ -336,9 +332,9 @@ class StockHomeState extends State { children: [ _buildStockTab(context, StockHomeTab.market, config.symbols), _buildStockTab(context, StockHomeTab.portfolio, portfolioSymbols), - ] - ) - ) + ], + ), + ), ); } } @@ -351,9 +347,11 @@ class _CreateCompanySheet extends StatelessWidget { children: [ new TextField( autofocus: true, - hintText: 'Company Name', + decoration: const InputDecoration( + hintText: 'Company Name', + ), ), - ] + ], ); } } diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 295ec45a440..cd4c2f73a2b 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -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'; diff --git a/packages/flutter/lib/src/foundation/change_notifier.dart b/packages/flutter/lib/src/foundation/change_notifier.dart index 608deb067c1..f1839d85e95 100644 --- a/packages/flutter/lib/src/foundation/change_notifier.dart +++ b/packages/flutter/lib/src/foundation/change_notifier.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. diff --git a/packages/flutter/lib/src/material/dropdown.dart b/packages/flutter/lib/src/material/dropdown.dart index c33ccc6c9c5..78624ca6984 100644 --- a/packages/flutter/lib/src/material/dropdown.dart +++ b/packages/flutter/lib/src/material/dropdown.dart @@ -462,7 +462,7 @@ class DropdownButton 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 diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart deleted file mode 100644 index 63ff387d79f..00000000000 --- a/packages/flutter/lib/src/material/input.dart +++ /dev/null @@ -1,754 +0,0 @@ -// 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 'colors.dart'; -import 'debug.dart'; -import 'icon.dart'; -import 'icon_theme.dart'; -import 'icon_theme_data.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. -/// * [EditableText], a text field that does not require [Material] design. -class InputField extends StatefulWidget { - InputField({ - Key key, - this.focusNode, - this.value, - this.keyboardType: TextInputType.text, - this.hintText, - this.style, - this.hintStyle, - this.obscureText: false, - this.maxLines: 1, - this.autofocus: false, - this.onChanged, - this.onSubmitted, - }) : super(key: key); - - /// Controls whether this widget has keyboard focus. - /// - /// If null, this widget will create its own [FocusNode]. - final FocusNode focusNode; - - /// The current state of text of the input field. This includes the selected - /// text, if any, among other things. - final InputValue value; - - /// The type of keyboard to use for editing the text. - final TextInputType keyboardType; - - /// Text to show inline in the input field when it would otherwise be empty. - final String hintText; - - /// The style to use for the text being edited. - final TextStyle style; - - /// The style to use for the hint text. - /// - /// Defaults to the specified TextStyle in style with the hintColor from - /// the ThemeData - final TextStyle hintStyle; - - /// 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; - - /// Whether this input field should focus itself if nothing else is already focused. - /// - /// Defaults to false. - final bool autofocus; - - /// Called when the text being edited changes. - /// - /// The [value] must be updated each time [onChanged] is invoked. - final ValueChanged onChanged; - - /// Called when the user indicates that they are done editing the text in the field. - final ValueChanged onSubmitted; - - @override - _InputFieldState createState() => new _InputFieldState(); -} - -class _InputFieldState extends State { - final GlobalKey _editableTextKey = new GlobalKey(); - - FocusNode _focusNode; - FocusNode get _effectiveFocusNode => config.focusNode ?? (_focusNode ??= new FocusNode()); - - @override - void dispose() { - _focusNode?.dispose(); - super.dispose(); - } - - void requestKeyboard() { - _editableTextKey.currentState?.requestKeyboard(); - } - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMaterial(context)); - final InputValue value = config.value ?? InputValue.empty; - final ThemeData themeData = Theme.of(context); - final TextStyle textStyle = config.style ?? themeData.textTheme.subhead; - - final List stackChildren = [ - new GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: requestKeyboard, - child: new EditableText( - key: _editableTextKey, - value: value, - focusNode: _effectiveFocusNode, - style: textStyle, - obscureText: config.obscureText, - maxLines: config.maxLines, - autofocus: config.autofocus, - cursorColor: themeData.textSelectionColor, - selectionColor: themeData.textSelectionColor, - selectionControls: materialTextSelectionControls, - keyboardType: config.keyboardType, - onChanged: config.onChanged, - onSubmitted: config.onSubmitted, - ), - ), - ]; - - if (config.hintText != null && value.text.isEmpty) { - final TextStyle hintStyle = config.hintStyle ?? - textStyle.copyWith(color: themeData.hintColor); - stackChildren.add( - new Positioned( - left: 0.0, - top: textStyle.fontSize - hintStyle.fontSize, - child: new IgnorePointer( - child: new Text(config.hintText, style: hintStyle), - ), - ), - ); - } - - return new RepaintBoundary(child: new Stack(children: stackChildren)); - } -} - -/// 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.showDivider: true, - 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). - /// - /// Defaults to false. - final bool isDense; - - /// True if the hint and label should be displayed as if the child had the focus. - /// - /// Defaults to false. - final bool focused; - - /// Should the hint and label be displayed as if no value had been input - /// to the child. - /// - /// Defaults to false. - final bool isEmpty; - - /// Whether to show a divider below the child and above the error text. - /// - /// Defaults to true. - final bool showDivider; - - /// The widget below this widget in the tree. - final Widget child; - - @override - _InputContainerState createState() => new _InputContainerState(); -} - -class _InputContainerState extends State { - @override - Widget build(BuildContext context) { - assert(debugCheckHasMaterial(context)); - final ThemeData themeData = Theme.of(context); - final 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; - - final 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 _AnimatedLabel( - text: config.labelText, - style: labelStyle, - duration: _kTransitionDuration, - curve: _kTransitionCurve, - ) - ), - ); - - topPadding += topPaddingIncrement; - } - - if (config.hintText != null) { - final TextStyle hintStyle = textStyle.copyWith(color: themeData.hintColor); - stackChildren.add( - new Positioned( - left: 0.0, - top: topPadding + textStyle.fontSize - hintStyle.fontSize, - child: new AnimatedOpacity( - opacity: (config.isEmpty && !hasInlineLabel) ? 1.0 : 0.0, - duration: _kTransitionDuration, - curve: _kTransitionCurve, - child: new IgnorePointer( - child: new Text(config.hintText, style: hintStyle), - ), - ), - ), - ); - } - - final Color borderColor = errorText == null ? activeColor : themeData.errorColor; - final double bottomPadding = config.isDense ? 8.0 : 1.0; - final double bottomBorder = 2.0; - final double bottomHeight = config.isDense ? 14.0 : 18.0; - - final EdgeInsets padding = new EdgeInsets.only(top: topPadding, bottom: bottomPadding); - final Border border = new Border( - bottom: new BorderSide( - color: borderColor, - width: bottomBorder, - ) - ); - final EdgeInsets margin = new EdgeInsets.only(bottom: bottomHeight - (bottomPadding + bottomBorder)); - - Widget divider; - if (!config.showDivider) { - divider = new Container( - margin: margin + new EdgeInsets.only(bottom: bottomBorder), - padding: padding, - child: config.child, - ); - } else { - divider = new AnimatedContainer( - margin: margin, - padding: padding, - duration: _kTransitionDuration, - curve: _kTransitionCurve, - decoration: new BoxDecoration( - border: border, - ), - child: config.child, - ); - } - stackChildren.add(divider); - - if (!config.isDense) { - final 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) { - final double iconSize = config.isDense ? 18.0 : 24.0; - final double iconTop = topPadding + (textStyle.fontSize - iconSize) / 2.0; - textField = new Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Container( - margin: new EdgeInsets.only(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 Expanded(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. -/// -/// When using inside a [Form], consider using [TextField] instead. -/// -/// Assuming that the input is already focused, the basic data flow for -/// retrieving user input is: -/// 1. User taps a character on the keyboard. -/// 2. The [onChanged] callback is called with the current [InputValue]. -/// 3. Perform any necessary logic/validation on the current input value. -/// 4. Update the state of the [Input] widget accordingly through [State.setState]. -/// -/// For most cases, we recommend that you use the [Input] class within a -/// [StatefulWidget] so you can save and operate on the current value of the -/// input. -/// -/// See also: -/// -/// * -/// * [TextField], which simplifies steps 2-4 above. -class Input extends StatefulWidget { - /// Creates a text input field. - /// - /// By default, the input uses a keyboard appropriate for text entry. - // - // If you change this constructor signature, please also update - // InputContainer, TextField, InputField. - Input({ - Key key, - this.value, - this.focusNode, - this.keyboardType: TextInputType.text, - this.icon, - this.labelText, - this.hintText, - this.errorText, - this.style, - this.obscureText: false, - this.showDivider: true, - this.isDense: false, - this.autofocus: false, - this.maxLines: 1, - this.onChanged, - this.onSubmitted, - }) : super(key: key); - - /// The current state of text of the input field. This includes the selected - /// text, if any, among other things. - final InputValue value; - - /// Controls whether this widget has keyboard focus. - /// - /// If null, this widget will create its own [FocusNode]. - final FocusNode focusNode; - - /// The type of keyboard to use for editing the text. - final TextInputType keyboardType; - - /// 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 to show above the input field. - final String labelText; - - /// Text to show inline in the input field when it would otherwise be empty. - final String hintText; - - /// Text to show when the input text is invalid. - final String errorText; - - /// The style to use for the text being edited. - final TextStyle style; - - /// 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; - - /// Whether to show a divider below the child and above the error text. - /// - /// Defaults to true. - final bool showDivider; - - /// Whether the input field is part of a dense form (i.e., uses less vertical space). - /// If true, [errorText] is not shown. - /// - /// Defaults to false. - final bool isDense; - - /// 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; - - /// 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. - /// - /// The [value] must be updated each time [onChanged] is invoked. - final ValueChanged onChanged; - - /// Called when the user indicates that they are done editing the text in the field. - final ValueChanged onSubmitted; - - @override - _InputState createState() => new _InputState(); -} - -class _InputState extends State { - final GlobalKey<_InputFieldState> _inputFieldKey = new GlobalKey<_InputFieldState>(); - - FocusNode _focusNode; - FocusNode get _effectiveFocusNode => config.focusNode ?? (_focusNode ??= new FocusNode()); - - @override - void dispose() { - _focusNode?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bool isEmpty = (config.value ?? InputValue.empty).text.isEmpty; - final FocusNode focusNode = _effectiveFocusNode; - - return new GestureDetector( - onTap: () { - _inputFieldKey.currentState?.requestKeyboard(); - }, - child: new AnimatedBuilder( - animation: focusNode, - builder: (BuildContext context, Widget child) { - return new InputContainer( - focused: focusNode.hasFocus, - isEmpty: isEmpty, - icon: config.icon, - labelText: config.labelText, - hintText: config.hintText, - errorText: config.errorText, - style: config.style, - isDense: config.isDense, - showDivider: config.showDivider, - child: child, - ); - }, - child: new InputField( - key: _inputFieldKey, - focusNode: focusNode, - value: config.value, - style: config.style, - obscureText: config.obscureText, - maxLines: config.maxLines, - autofocus: config.autofocus, - keyboardType: config.keyboardType, - onChanged: config.onChanged, - onSubmitted: config.onSubmitted, - ), - ), - ); - } -} - -/// A [FormField] that contains an [Input]. -/// -/// This is a convenience widget that simply wraps an [Input] widget in a -/// [FormField]. The [FormField] maintains the current value of the [Input] so -/// that you don't need to manage it yourself. -/// -/// 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. -/// -/// To see the use of [TextField], compare these two ways of a implementing -/// a simple two text field form. -/// -/// Using [TextField]: -/// -/// ```dart -/// String _firstName, _lastName; -/// GlobalKey _formKey = new GlobalKey(); -/// ... -/// new Form( -/// key: _formKey, -/// child: new Row( -/// children: [ -/// new TextField( -/// labelText: 'First Name', -/// onSaved: (InputValue value) { _firstName = value.text; } -/// ), -/// new TextField( -/// labelText: 'Last Name', -/// onSaved: (InputValue value) { _lastName = value.text; } -/// ), -/// new RaisedButton( -/// child: new Text('SUBMIT'), -/// // Instead of _formKey.currentState, you could wrap the -/// // RaisedButton in a Builder widget to get access to a BuildContext, -/// // and use Form.of(context). -/// onPressed: () { _formKey.currentState.save(); }, -/// ), -/// ) -/// ) -/// ``` -/// -/// Using [Input] directly: -/// -/// ```dart -/// String _firstName, _lastName; -/// InputValue _firstNameValue = const InputValue(); -/// InputValue _lastNameValue = const InputValue(); -/// ... -/// new Row( -/// children: [ -/// new Input( -/// value: _firstNameValue, -/// labelText: 'First Name', -/// onChanged: (InputValue value) { setState( () { _firstNameValue = value; } ); } -/// ), -/// new Input( -/// value: _lastNameValue, -/// labelText: 'Last Name', -/// onChanged: (InputValue value) { setState( () { _lastNameValue = value; } ); } -/// ), -/// new RaisedButton( -/// child: new Text('SUBMIT'), -/// onPressed: () { -/// _firstName = _firstNameValue.text; -/// _lastName = _lastNameValue.text; -/// }, -/// ), -/// ) -/// ``` -class TextField extends FormField { - TextField({ - Key key, - FocusNode focusNode, - TextInputType keyboardType: TextInputType.text, - Icon icon, - String labelText, - String hintText, - TextStyle style, - bool obscureText: false, - bool isDense: false, - bool autofocus: false, - int maxLines: 1, - InputValue initialValue: InputValue.empty, - FormFieldSetter onSaved, - FormFieldValidator validator, - ValueChanged onChanged, - }) : super( - key: key, - initialValue: initialValue, - onSaved: onSaved, - validator: validator, - builder: (FormFieldState field) { - return new Input( - focusNode: focusNode, - keyboardType: keyboardType, - icon: icon, - labelText: labelText, - hintText: hintText, - style: style, - obscureText: obscureText, - isDense: isDense, - autofocus: autofocus, - maxLines: maxLines, - value: field.value, - onChanged: (InputValue value) { - field.onChanged(value); - if (onChanged != null) - onChanged(value); - }, - errorText: field.errorText, - ); - }, - ); -} - -// Helper widget to smoothly animate the labelText of an Input, as it -// transitions between inline and caption. -class _AnimatedLabel extends ImplicitlyAnimatedWidget { - _AnimatedLabel({ - Key key, - this.text, - @required this.style, - Curve curve: Curves.linear, - Duration duration, - }) : super(key: key, curve: curve, duration: duration) { - assert(style != null); - } - - final String text; - final TextStyle style; - - @override - _AnimatedLabelState createState() => new _AnimatedLabelState(); - - @override - void debugFillDescription(List description) { - super.debugFillDescription(description); - '$style'.split('\n').forEach(description.add); - } -} - -class _AnimatedLabelState extends AnimatedWidgetBaseState<_AnimatedLabel> { - TextStyleTween _style; - - @override - void forEachTween(TweenVisitor visitor) { - _style = visitor(_style, config.style, (dynamic value) => new TextStyleTween(begin: value)); - } - - @override - Widget build(BuildContext context) { - TextStyle style = _style.evaluate(animation); - double scale = 1.0; - if (style.fontSize != config.style.fontSize) { - // While the fontSize is transitioning, use a scaled Transform as a - // fraction of the original fontSize. That way we get a smooth scaling - // effect with no snapping between discrete font sizes. - scale = style.fontSize / config.style.fontSize; - style = style.copyWith(fontSize: config.style.fontSize); - } - - return new Transform( - transform: new Matrix4.identity()..scale(scale), - child: new Text( - config.text, - style: style, - ) - ); - } -} diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart new file mode 100644 index 00000000000..a5f7d9bf529 --- /dev/null +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -0,0 +1,498 @@ +// 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/widgets.dart'; + +import 'colors.dart'; +import 'debug.dart'; +import 'icon.dart'; +import 'icon_theme.dart'; +import 'icon_theme_data.dart'; +import 'theme.dart'; + +const Duration _kTransitionDuration = const Duration(milliseconds: 200); +const Curve _kTransitionCurve = Curves.fastOutSlowIn; + +/// Text and styles used to label an input field. +/// +/// See also: +/// +/// * [TextField], which is a text input widget that uses an +/// [InputDecoration]. +/// * [InputDecorator], which is a widget that draws an [InputDecoration] +/// around an arbitrary child widget. +class InputDecoration { + /// Creates a bundle of text and styles used to label an input field. + /// + /// Sets the [isCollapsed] property to false. To create a decoration that does + /// not reserve space for [labelText] or [errorText], use + /// [InputDecoration.collapsed]. + const InputDecoration({ + this.icon, + this.labelText, + this.labelStyle, + this.hintText, + this.hintStyle, + this.errorText, + this.errorStyle, + this.isDense: false, + this.hideDivider: false, + }) : isCollapsed = false; + + /// Creates a decoration that is the same size as the input field. + /// + /// This type of input decoration does not include a divider or an icon and + /// does not reserve space for [labelText] or [errorText]. + /// + /// Sets the [isCollapsed] property to true. + const InputDecoration.collapsed({ + @required this.hintText, + this.hintStyle, + }) : icon = null, + labelText = null, + labelStyle = null, + errorText = null, + errorStyle = null, + isDense = false, + isCollapsed = true, + hideDivider = true; + + /// An icon to show before 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 describes the input field. + /// + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text my be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), the label moves above (i.e., + /// vertically adjacent to) the input field. + final String labelText; + + /// The style to use for the [labelText] when the label is above (i.e., + /// vertically adjacent to) the input field. + /// + /// When the [labelText] is on top of the input field, the text uses the + /// [hintStyle] instead. + /// + /// If null, defaults of a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + final TextStyle labelStyle; + + /// Text that suggests what sort of input the field accepts. + /// + /// Displayed on top of the input field (i.e., at the same location on the + /// screen where text my be entered in the input field) when the input field + /// is empty and either (a) [labelText] is null or (b) the input field has + /// focus. + final String hintText; + + /// The style to use for the [hintText]. + /// + /// Also used for the [labelText] when the [labelText] is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text my be entered in the input field). + /// + /// If null, defaults of a value derived from the base [TextStyle] for the + /// input field and the current [Theme]. + final TextStyle hintStyle; + + /// Text that appears below the input field. + /// + /// If non-null the divider, that appears below the input field is red. + final String 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]. + final TextStyle errorStyle; + + /// Whether the input field is part of a dense form (i.e., uses less vertical + /// space). + /// + /// Defaults to false. + final bool isDense; + + /// Whether the decoration is the same size as the input field. + /// + /// A collapsed decoration cannot have [labelText], [errorText], an [icon], or + /// a divider because those elements require extra space. + /// + /// To create a collapsed input decoration, use [InputDecoration..collapsed]. + final bool isCollapsed; + + /// Whether to hide the divider below the input field and above the error text. + /// + /// Defaults to false. + final bool hideDivider; + + /// Creates a copy of this input decoration but with the given fields replaced + /// with the new values. + /// + /// Always sets [isCollapsed] to false. + InputDecoration copyWith({ + Widget icon, + String labelText, + TextStyle labelStyle, + String hintText, + TextStyle hintStyle, + String errorText, + TextStyle errorStyle, + bool isDense, + bool hideDivider, + }) { + return new InputDecoration( + icon: icon ?? this.icon, + labelText: labelText ?? this.labelText, + labelStyle: labelStyle ?? this.labelStyle, + hintText: hintText ?? this.hintText, + hintStyle: hintStyle ?? this.hintStyle, + errorText: errorText ?? this.errorText, + errorStyle: errorStyle ?? this.errorStyle, + isDense: isDense ?? this.isDense, + hideDivider: hideDivider ?? this.hideDivider, + ); + } + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final InputDecoration typedOther = other; + return typedOther.icon == icon + && typedOther.labelText == labelText + && typedOther.labelStyle == labelStyle + && typedOther.hintText == hintText + && typedOther.hintStyle == hintStyle + && typedOther.errorText == errorText + && typedOther.errorStyle == errorStyle + && typedOther.isDense == isDense + && typedOther.isCollapsed == isCollapsed + && typedOther.hideDivider == hideDivider; + } + + @override + int get hashCode { + return hashValues( + icon, + labelText, + labelStyle, + hintText, + hintStyle, + errorText, + errorStyle, + isDense, + isCollapsed, + hideDivider, + ); + } + + @override + String toString() { + final List description = []; + if (icon != null) + description.add('icon: $icon'); + if (labelText != null) + description.add('labelText: "$labelText"'); + if (hintText != null) + description.add('hintText: "$hintText"'); + if (errorText != null) + description.add('errorText: "$errorText"'); + if (isDense) + description.add('isDense: $isDense'); + if (isCollapsed) + description.add('isCollapsed: $isCollapsed'); + if (hideDivider) + description.add('hideDivider: $hideDivider'); + return 'InputDecoration(${description.join(', ')})'; + } +} + +/// Displays the visual elements of a Material Design text field around an +/// arbitrary widget. +/// +/// Use [InputDecorator] to create widgets that look and behave like a +/// [TextField] but can be used to input information other than text. +/// +/// Requires one of its ancestors to be a [Material] widget. +/// +/// See also: +/// +/// * [TextField], which uses an [InputDecorator] to draw labels and other +/// visual elements around a text entry widget. +class InputDecorator extends StatelessWidget { + /// Creates a widget that displayes labels and other visual elements similar + /// to a [TextField]. + InputDecorator({ + Key key, + @required this.decoration, + this.baseStyle, + this.isFocused: false, + this.isEmpty: false, + this.child, + }) : super(key: key); + + /// The text and styles to use when decorating the child. + final InputDecoration decoration; + + /// The style on which to base the label, hint, and error styles if the + /// [decoration] does not provide explicit styles. + /// + /// If null, defaults to a text style from the current [Theme]. + final TextStyle baseStyle; + + /// Whether the input field has focus. + /// + /// Determines the position of the label text and the color of the divider. + /// + /// Defaults to false. + final bool isFocused; + + /// Whether the input field is empty. + /// + /// Determines the position of the label text and whether to display the hint + /// text. + /// + /// Defaults to false. + final bool isEmpty; + + /// The widget below this widget in the tree. + final Widget child; + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('decoration: $decoration'); + description.add('baseStyle: $baseStyle'); + description.add('isFocused: $isFocused'); + description.add('isEmpty: $isEmpty'); + } + + Color _getActiveColor(ThemeData themeData) { + if (isFocused) { + switch (themeData.brightness) { + case Brightness.dark: + return themeData.accentColor; + case Brightness.light: + return themeData.primaryColor; + } + } + return themeData.hintColor; + } + + Widget _buildContent(Color borderColor, double topPadding, bool isDense) { + final double bottomPadding = isDense ? 8.0 : 1.0; + const double bottomBorder = 2.0; + final double bottomHeight = isDense ? 14.0 : 18.0; + + final EdgeInsets padding = new EdgeInsets.only(top: topPadding, bottom: bottomPadding); + final EdgeInsets margin = new EdgeInsets.only(bottom: bottomHeight - (bottomPadding + bottomBorder)); + + if (decoration.hideDivider) { + return new Container( + margin: margin + new EdgeInsets.only(bottom: bottomBorder), + padding: padding, + child: child, + ); + } + + return new AnimatedContainer( + margin: margin, + padding: padding, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + decoration: new BoxDecoration( + border: new Border( + bottom: new BorderSide( + color: borderColor, + width: bottomBorder, + ), + ), + ), + child: child, + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + final ThemeData themeData = Theme.of(context); + + final bool isDense = decoration.isDense; + final bool isCollapsed = decoration.isCollapsed; + assert(!isDense || !isCollapsed); + + final String labelText = decoration.labelText; + final String hintText = decoration.hintText; + final String errorText = decoration.errorText; + + final TextStyle baseStyle = this.baseStyle ?? themeData.textTheme.subhead; + final TextStyle hintStyle = decoration.hintStyle ?? baseStyle.copyWith(color: themeData.hintColor); + + final Color activeColor = _getActiveColor(themeData); + + double topPadding = isDense ? 12.0 : 16.0; + + final 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 = !isFocused && labelText != null && isEmpty; + + if (labelText != null) { + assert(!isCollapsed); + final TextStyle labelStyle = hasInlineLabel ? + hintStyle : (decoration.labelStyle ?? themeData.textTheme.caption.copyWith(color: activeColor)); + + final double topPaddingIncrement = themeData.textTheme.caption.fontSize + (isDense ? 4.0 : 8.0); + double top = topPadding; + if (hasInlineLabel) + top += topPaddingIncrement + baseStyle.fontSize - labelStyle.fontSize; + + stackChildren.add( + new AnimatedPositioned( + left: 0.0, + top: top, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + child: new _AnimatedLabel( + text: labelText, + style: labelStyle, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + ), + ), + ); + + topPadding += topPaddingIncrement; + } + + if (hintText != null) { + stackChildren.add( + new Positioned( + left: 0.0, + top: topPadding + baseStyle.fontSize - hintStyle.fontSize, + child: new AnimatedOpacity( + opacity: (isEmpty && !hasInlineLabel) ? 1.0 : 0.0, + duration: _kTransitionDuration, + curve: _kTransitionCurve, + child: new Text(hintText, style: hintStyle), + ), + ), + ); + } + + if (isCollapsed) { + stackChildren.add(child); + } else { + final Color borderColor = errorText == null ? activeColor : themeData.errorColor; + stackChildren.add(_buildContent(borderColor, topPadding, isDense)); + } + + if (!isDense && errorText != null) { + assert(!isCollapsed); + final TextStyle errorStyle = decoration.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 result = new Stack(children: stackChildren); + + if (decoration.icon != null) { + assert(!isCollapsed); + final double iconSize = isDense ? 18.0 : 24.0; + final double iconTop = topPadding + (baseStyle.fontSize - iconSize) / 2.0; + result = new Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + new Container( + margin: new EdgeInsets.only(top: iconTop), + width: isDense ? 40.0 : 48.0, + child: new IconTheme.merge( + context: context, + data: new IconThemeData( + color: isFocused ? activeColor : Colors.black45, + size: isDense ? 18.0 : 24.0, + ), + child: decoration.icon, + ), + ), + new Expanded(child: result), + ], + ); + } + + return result; + } +} + +// Smoothly animate the label of an InputDecorator as the label +// transitions between inline and caption. +class _AnimatedLabel extends ImplicitlyAnimatedWidget { + _AnimatedLabel({ + Key key, + this.text, + @required this.style, + Curve curve: Curves.linear, + @required Duration duration, + }) : super(key: key, curve: curve, duration: duration) { + assert(style != null); + } + + final String text; + final TextStyle style; + + @override + _AnimatedLabelState createState() => new _AnimatedLabelState(); + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + '$style'.split('\n').forEach(description.add); + } +} + +class _AnimatedLabelState extends AnimatedWidgetBaseState<_AnimatedLabel> { + TextStyleTween _style; + + @override + void forEachTween(TweenVisitor visitor) { + _style = visitor(_style, config.style, (dynamic value) => new TextStyleTween(begin: value)); + } + + @override + Widget build(BuildContext context) { + TextStyle style = _style.evaluate(animation); + double scale = 1.0; + if (style.fontSize != config.style.fontSize) { + // While the fontSize is transitioning, use a scaled Transform as a + // fraction of the original fontSize. That way we get a smooth scaling + // effect with no snapping between discrete font sizes. + scale = style.fontSize / config.style.fontSize; + style = style.copyWith(fontSize: config.style.fontSize); + } + + return new Transform( + transform: new Matrix4.identity()..scale(scale), + child: new Text( + config.text, + style: style, + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart new file mode 100644 index 00000000000..e6007e55a20 --- /dev/null +++ b/packages/flutter/lib/src/material/text_field.dart @@ -0,0 +1,268 @@ +// 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: +/// +/// * +/// * [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 onChanged; + + /// Called when the user indicates that they are done editing the text in the + /// field. + final ValueChanged onSubmitted; + + @override + _TextFieldState createState() => new _TextFieldState(); + + @override + void debugFillDescription(List 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 { + final GlobalKey _editableTextKey = new GlobalKey(); + + 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([ 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, + ); + } +} diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart new file mode 100644 index 00000000000..456cab387f2 --- /dev/null +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -0,0 +1,62 @@ +// 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: +/// +/// * +/// * [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 { + 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 onSaved, + FormFieldValidator validator, + }) : super( + key: key, + initialValue: controller != null ? controller.value.text : '', + onSaved: onSaved, + validator: validator, + builder: (FormFieldState 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); + }, + ); + }, + ); +} diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index 34b4091e31b..96a9edd7fbb 100644 --- a/packages/flutter/lib/src/material/text_selection.dart +++ b/packages/flutter/lib/src/material/text_selection.dart @@ -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 _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(); diff --git a/packages/flutter/lib/src/services/message_codec.dart b/packages/flutter/lib/src/services/message_codec.dart index 6b8b3f2107f..af264d40a8e 100644 --- a/packages/flutter/lib/src/services/message_codec.dart +++ b/packages/flutter/lib/src/services/message_codec.dart @@ -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) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 2320c8d9476..0a6d78eb607 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; import 'basic.dart'; import 'focus_manager.dart'; @@ -18,91 +19,48 @@ import 'scroll_physics.dart'; import 'scrollable.dart'; import 'text_selection.dart'; -export 'package:flutter/services.dart' show TextSelection, TextInputType; +export 'package:flutter/services.dart' show TextEditingValue, TextSelection, TextInputType; const Duration _kCursorBlinkHalfPeriod = const Duration(milliseconds: 500); -InputValue _getInputValueFromEditingValue(TextEditingValue value) { - return new InputValue( - text: value.text, - selection: value.selection, - composing: value.composing, - ); -} +class TextEditingController extends ChangeNotifier { + TextEditingController({ String text }) + : _value = text == null ? TextEditingValue.empty : new TextEditingValue(text: text); -TextEditingValue _getTextEditingValueFromInputValue(InputValue value) { - return new TextEditingValue( - text: value.text, - selection: value.selection, - composing: value.composing, - ); -} + TextEditingController.fromValue(TextEditingValue value) + : _value = value ?? TextEditingValue.empty; -/// Configuration information for a text input field. -/// -/// An [InputValue] contains the text for the input field as well as the -/// selection extent and the composing range. -class InputValue { - // TODO(abarth): This class is really the same as TextEditingState. - // We should merge them into one object. + TextEditingValue get value => _value; + TextEditingValue _value; + set value(TextEditingValue newValue) { + assert(newValue != null); + if (_value == newValue) + return; + _value = newValue; + notifyListeners(); + } - /// Creates configuration information for an input field - /// - /// The selection and composing range must be within the text. - /// - /// The [text], [selection], and [composing] arguments must not be null but - /// each have default values. - const InputValue({ - this.text: '', - this.selection: const TextSelection.collapsed(offset: -1), - this.composing: TextRange.empty - }); + String get text => _value.text; + set text(String newText) { + value = value.copyWith(text: newText, composing: TextRange.empty); + } - /// The current text being edited. - final String text; + TextSelection get selection => _value.selection; + set selection(TextSelection newSelection) { + value = value.copyWith(selection: newSelection, composing: TextRange.empty); + } - /// The range of text that is currently selected. - final TextSelection selection; + void clear() { + value = TextEditingValue.empty; + } - /// The range of text that is still being composed. - final TextRange composing; - - /// An input value that corresponds to the empty string with no selection and no composing range. - static const InputValue empty = const InputValue(); - - @override - String toString() => '$runtimeType(text: \u2524$text\u251C, selection: $selection, composing: $composing)'; - - @override - bool operator ==(dynamic other) { - if (identical(this, other)) - return true; - if (other is! InputValue) - return false; - final InputValue typedOther = other; - return typedOther.text == text - && typedOther.selection == selection - && typedOther.composing == composing; + void clearComposing() { + value = value.copyWith(composing: TextRange.empty); } @override - int get hashCode => hashValues( - text.hashCode, - selection.hashCode, - composing.hashCode - ); - - /// Creates a copy of this input value but with the given fields replaced with the new values. - InputValue copyWith({ - String text, - TextSelection selection, - TextRange composing - }) { - return new InputValue ( - text: text ?? this.text, - selection: selection ?? this.selection, - composing: composing ?? this.composing - ); + String toString() { + return '$runtimeType#$hashCode($value)'; } } @@ -126,11 +84,11 @@ class InputValue { class EditableText extends StatefulWidget { /// Creates a basic text input control. /// - /// The [value], [focusNode], [style], and [cursorColor] arguments must not - /// be null. + /// The [controller], [focusNode], [style], and [cursorColor] arguments must + /// not be null. EditableText({ Key key, - @required this.value, + @required this.controller, @required this.focusNode, this.obscureText: false, @required this.style, @@ -144,7 +102,7 @@ class EditableText extends StatefulWidget { this.onChanged, this.onSubmitted, }) : super(key: key) { - assert(value != null); + assert(controller != null); assert(focusNode != null); assert(obscureText != null); assert(style != null); @@ -153,8 +111,8 @@ class EditableText extends StatefulWidget { assert(autofocus != null); } - /// The string being displayed in this widget. - final InputValue value; + /// Controls the text being edited. + final TextEditingController controller; /// Controls whether this widget has keyboard focus. final FocusNode focusNode; @@ -200,10 +158,10 @@ class EditableText extends StatefulWidget { final TextInputType keyboardType; /// Called when the text being edited changes. - final ValueChanged onChanged; + final ValueChanged onChanged; /// Called when the user indicates that they are done editing the text in the field. - final ValueChanged onSubmitted; + final ValueChanged onSubmitted; @override EditableTextState createState() => new EditableTextState(); @@ -214,17 +172,18 @@ class EditableTextState extends State implements TextInputClient { Timer _cursorTimer; bool _showCursor = false; - InputValue _currentValue; TextInputConnection _textInputConnection; TextSelectionOverlay _selectionOverlay; final ScrollController _scrollController = new ScrollController(); bool _didAutoFocus = false; + // State lifecycle: + @override void initState() { super.initState(); - _currentValue = config.value; + config.controller.addListener(_didChangeTextEditingValue); config.focusNode.addListener(_handleFocusChanged); } @@ -240,17 +199,63 @@ class EditableTextState extends State implements TextInputClient { @override void didUpdateConfig(EditableText oldConfig) { - if (_currentValue != config.value) { - _currentValue = config.value; - if (_isAttachedToKeyboard) - _textInputConnection.setEditingState(_getTextEditingValueFromInputValue(_currentValue)); - } + if (config.controller != oldConfig.controller) { + oldConfig.controller.removeListener(_didChangeTextEditingValue); + config.controller.addListener(_didChangeTextEditingValue); + if (_isAttachedToKeyboard && config.controller.value != oldConfig.controller.value) + _textInputConnection.setEditingState(config.controller.value); + } if (config.focusNode != oldConfig.focusNode) { oldConfig.focusNode.removeListener(_handleFocusChanged); config.focusNode.addListener(_handleFocusChanged); } } + @override + void dispose() { + config.controller.removeListener(_didChangeTextEditingValue); + if (_isAttachedToKeyboard) { + _textInputConnection.close(); + _textInputConnection = null; + } + assert(!_isAttachedToKeyboard); + if (_cursorTimer != null) + _stopCursorTimer(); + assert(_cursorTimer == null); + _selectionOverlay?.dispose(); + _selectionOverlay = null; + config.focusNode.removeListener(_handleFocusChanged); + super.dispose(); + } + + // TextInputClient implementation: + + @override + void updateEditingValue(TextEditingValue value) { + if (value.text != _value.text) + _hideSelectionOverlayIfNeeded(); + _value = value; + if (config.onChanged != null) + config.onChanged(value.text); + } + + @override + void performAction(TextInputAction action) { + config.controller.clearComposing(); + config.focusNode.unfocus(); + if (config.onSubmitted != null) + config.onSubmitted(_value.text); + } + + TextEditingValue get _value => config.controller.value; + set _value(TextEditingValue value) { + config.controller.value = value; + } + + void _didChangeTextEditingValue() { + setState(() { /* We use config.controller.value in build(). */ }); + } + bool get _isAttachedToKeyboard => _textInputConnection != null && _textInputConnection.attached; bool get _isMultiline => config.maxLines > 1; @@ -273,24 +278,18 @@ class EditableTextState extends State implements TextInputClient { void _attachOrDetachKeyboard(bool focused) { if (focused && !_isAttachedToKeyboard && _didRequestKeyboard) { _textInputConnection = TextInput.attach(this, new TextInputConfiguration(inputType: config.keyboardType)) - ..setEditingState(_getTextEditingValueFromInputValue(_currentValue)) + ..setEditingState(_value) ..show(); } else if (!focused) { if (_isAttachedToKeyboard) { _textInputConnection.close(); _textInputConnection = null; } - _clearComposing(); + config.controller.clearComposing(); } _didRequestKeyboard = false; } - void _clearComposing() { - // TODO(abarth): We should call config.onChanged to notify our parent of - // this change in our composing range. - _currentValue = _currentValue.copyWith(composing: TextRange.empty); - } - /// Express interest in interacting with the keyboard. /// /// If this control is already attached to the keyboard, this function will @@ -310,23 +309,9 @@ class EditableTextState extends State implements TextInputClient { } } - @override - void updateEditingValue(TextEditingValue value) { - _currentValue = _getInputValueFromEditingValue(value); - if (config.onChanged != null) - config.onChanged(_currentValue); - if (_currentValue.text != config.value.text) { - _selectionOverlay?.hide(); - _selectionOverlay = null; - } - } - - @override - void performAction(TextInputAction action) { - _clearComposing(); - config.focusNode.unfocus(); - if (config.onSubmitted != null) - config.onSubmitted(_currentValue); + void _hideSelectionOverlayIfNeeded() { + _selectionOverlay?.hide(); + _selectionOverlay = null; } void _handleSelectionChanged(TextSelection selection, RenderEditable renderObject, bool longPress) { @@ -334,45 +319,40 @@ class EditableTextState extends State implements TextInputClient { // EditableWidget, not just changes triggered by user gestures. requestKeyboard(); - final InputValue newInput = _currentValue.copyWith(selection: selection, composing: TextRange.empty); - if (config.onChanged != null) - config.onChanged(newInput); - - if (_selectionOverlay != null) { - _selectionOverlay.hide(); - _selectionOverlay = null; - } + _hideSelectionOverlayIfNeeded(); + config.controller.selection = selection; if (config.selectionControls != null) { _selectionOverlay = new TextSelectionOverlay( - input: newInput, context: context, + value: _value, debugRequiredFor: config, renderObject: renderObject, onSelectionOverlayChanged: _handleSelectionOverlayChanged, selectionControls: config.selectionControls, ); - if (newInput.text.isNotEmpty || longPress) + if (_value.text.isNotEmpty || longPress) _selectionOverlay.showHandles(); if (longPress) _selectionOverlay.showToolbar(); } } - void _handleSelectionOverlayChanged(InputValue newInput, Rect caretRect) { - assert(!newInput.composing.isValid); // composing range must be empty while selecting - if (config.onChanged != null) - config.onChanged(newInput); + void _handleSelectionOverlayChanged(TextEditingValue value, Rect caretRect) { + assert(!value.composing.isValid); // composing range must be empty while selecting. + _value = value; _scrollController.jumpTo(_getScrollOffsetForCaret(caretRect)); } /// Whether the blinking cursor is actually visible at this precise moment /// (it's hidden half the time, since it blinks). + @visibleForTesting bool get cursorCurrentlyVisible => _showCursor; /// The cursor blink interval (the amount of time the cursor is in the "on" /// state or the "off" state). A complete cursor blink period is twice this /// value (half on, half off). + @visibleForTesting Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod; void _cursorTick(Timer timer) { @@ -390,37 +370,21 @@ class EditableTextState extends State implements TextInputClient { final bool focused = config.focusNode.hasFocus; _attachOrDetachKeyboard(focused); - if (_cursorTimer == null && focused && config.value.selection.isCollapsed) + if (_cursorTimer == null && focused && _value.selection.isCollapsed) _startCursorTimer(); - else if (_cursorTimer != null && (!focused || !config.value.selection.isCollapsed)) + else if (_cursorTimer != null && (!focused || !_value.selection.isCollapsed)) _stopCursorTimer(); if (_selectionOverlay != null) { if (focused) { - _selectionOverlay.update(config.value); + _selectionOverlay.update(_value); } else { - _selectionOverlay?.dispose(); + _selectionOverlay.dispose(); _selectionOverlay = null; } } } - @override - void dispose() { - if (_isAttachedToKeyboard) { - _textInputConnection.close(); - _textInputConnection = null; - } - assert(!_isAttachedToKeyboard); - if (_cursorTimer != null) - _stopCursorTimer(); - assert(_cursorTimer == null); - _selectionOverlay?.dispose(); - _selectionOverlay = null; - config.focusNode.removeListener(_handleFocusChanged); - super.dispose(); - } - void _stopCursorTimer() { _cursorTimer.cancel(); _cursorTimer = null; @@ -436,7 +400,7 @@ class EditableTextState extends State implements TextInputClient { physics: const ClampingScrollPhysics(), viewportBuilder: (BuildContext context, ViewportOffset offset) { return new _Editable( - value: _currentValue, + value: _value, style: config.style, cursorColor: config.cursorColor, showCursor: _showCursor, @@ -467,7 +431,7 @@ class _Editable extends LeafRenderObjectWidget { this.onSelectionChanged, }) : super(key: key); - final InputValue value; + final TextEditingValue value; final TextStyle style; final Color cursorColor; final bool showCursor; diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 824908a0dc6..80f75778d12 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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 get _handleOpacity => _handleController.view; Animation 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); } } diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index 195337cc63c..8758c074992 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -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); diff --git a/packages/flutter/test/widgets/input_test.dart b/packages/flutter/test/material/text_field_test.dart similarity index 72% rename from packages/flutter/test/widgets/input_test.dart rename to packages/flutter/test/material/text_field_test.dart index 0632836bcb4..dca33f005d9 100644 --- a/packages/flutter/test/widgets/input_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -12,7 +12,7 @@ import 'package:flutter/services.dart'; class MockClipboard { Object _clipboardData = { - 'text': null + 'text': null, }; Future handleMethodCall(MethodCall methodCall) async { @@ -30,9 +30,9 @@ Widget overlay(Widget child) { return new Overlay( initialEntries: [ new OverlayEntry( - builder: (BuildContext context) => child - ) - ] + builder: (BuildContext context) => child, + ), + ], ); } @@ -74,28 +74,31 @@ void main() { return endpoints[0].point + const Offset(0.0, -2.0); } - testWidgets('Editable text has consistent size', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + testWidgets('TextField has consistent size', (WidgetTester tester) async { + final Key textFieldKey = new UniqueKey(); + String textFieldValue; Widget builder() { return new Center( child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - hintText: 'Placeholder', - onChanged: (InputValue value) { inputValue = value; } - ) - ) + child: new TextField( + key: textFieldKey, + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + onChanged: (String value) { + textFieldValue = value; + } + ), + ), ); } await tester.pumpWidget(builder()); - RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); + RenderBox findTextFieldBox() => tester.renderObject(find.byKey(textFieldKey)); - final RenderBox inputBox = findInputBox(); + final RenderBox inputBox = findTextFieldBox(); final Size emptyInputSize = inputBox.size; Future checkText(String testValue) async { @@ -103,31 +106,31 @@ void main() { await tester.idle(); // Check that the onChanged event handler fired. - expect(inputValue.text, equals(testValue)); + expect(textFieldValue, equals(testValue)); return await tester.pumpWidget(builder()); } await checkText(' '); - expect(findInputBox(), equals(inputBox)); + expect(findTextFieldBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); await checkText('Test'); - expect(findInputBox(), equals(inputBox)); + expect(findTextFieldBox(), equals(inputBox)); expect(inputBox.size, equals(emptyInputSize)); }); testWidgets('Cursor blinks', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); Widget builder() { return new Center( child: new Material( - child: new Input( - key: inputKey, - hintText: 'Placeholder' - ) - ) + child: new TextField( + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + ), + ), ); } @@ -163,17 +166,16 @@ void main() { }); testWidgets('obscureText control test', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - Widget builder() { return new Center( child: new Material( - child: new Input( - key: inputKey, + child: new TextField( obscureText: true, - hintText: 'Placeholder' - ) - ) + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + ), + ), ); } @@ -190,8 +192,7 @@ void main() { }); testWidgets('Can long press to select', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final TextEditingController controller = new TextEditingController(); Widget builder() { return new Overlay( @@ -200,16 +201,14 @@ void main() { builder: (BuildContext context) { return new Center( child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - onChanged: (InputValue value) { inputValue = value; } - ) - ) + child: new TextField( + controller: controller, + ), + ), ); - } - ) - ] + }, + ), + ], ); } @@ -218,11 +217,11 @@ void main() { final String testValue = 'abc def ghi'; await tester.enterText(find.byType(EditableText), testValue); await tester.idle(); - expect(inputValue.text, testValue); + expect(controller.value.text, testValue); await tester.pumpWidget(builder()); - expect(inputValue.selection.isCollapsed, true); + expect(controller.selection.isCollapsed, true); // Long press the 'e' to select 'def'. final Point ePos = textOffsetToPosition(tester, testValue.indexOf('e')); @@ -232,32 +231,21 @@ void main() { await tester.pump(); // 'def' is selected. - expect(inputValue.selection.baseOffset, testValue.indexOf('d')); - expect(inputValue.selection.extentOffset, testValue.indexOf('f')+1); + expect(controller.selection.baseOffset, testValue.indexOf('d')); + expect(controller.selection.extentOffset, testValue.indexOf('f')+1); }); testWidgets('Can drag handles to change selection', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final TextEditingController controller = new TextEditingController(); Widget builder() { - return new Overlay( - initialEntries: [ - new OverlayEntry( - builder: (BuildContext context) { - return new Center( - child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - onChanged: (InputValue value) { inputValue = value; } - ) - ) - ); - } - ) - ] - ); + return overlay(new Center( + child: new Material( + child: new TextField( + controller: controller, + ), + ), + )); } await tester.pumpWidget(builder()); @@ -275,7 +263,7 @@ void main() { await gesture.up(); await tester.pump(); - final TextSelection selection = inputValue.selection; + final TextSelection selection = controller.selection; final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = renderEditable.getEndpointsForSelection( @@ -294,8 +282,8 @@ void main() { await gesture.up(); await tester.pumpWidget(builder()); - expect(inputValue.selection.baseOffset, selection.baseOffset); - expect(inputValue.selection.extentOffset, selection.extentOffset+2); + expect(controller.selection.baseOffset, selection.baseOffset); + expect(controller.selection.extentOffset, selection.extentOffset+2); // Drag the left handle 2 letters to the left. handlePos = endpoints[0].point + const Offset(-1.0, 1.0); @@ -307,32 +295,21 @@ void main() { await gesture.up(); await tester.pumpWidget(builder()); - expect(inputValue.selection.baseOffset, selection.baseOffset-2); - expect(inputValue.selection.extentOffset, selection.extentOffset+2); + expect(controller.selection.baseOffset, selection.baseOffset-2); + expect(controller.selection.extentOffset, selection.extentOffset+2); }); testWidgets('Can use selection toolbar', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final TextEditingController controller = new TextEditingController(); Widget builder() { - return new Overlay( - initialEntries: [ - new OverlayEntry( - builder: (BuildContext context) { - return new Center( - child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - onChanged: (InputValue value) { inputValue = value; } - ) - ) - ); - } - ) - ] - ); + return overlay(new Center( + child: new Material( + child: new TextField( + controller: controller, + ), + ), + )); } await tester.pumpWidget(builder()); @@ -347,57 +324,46 @@ void main() { await tester.pumpWidget(builder()); RenderEditable renderEditable = findRenderEditable(tester); List endpoints = renderEditable.getEndpointsForSelection( - inputValue.selection); + controller.selection); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); await tester.pumpWidget(builder()); // SELECT ALL should select all the text. await tester.tap(find.text('SELECT ALL')); await tester.pumpWidget(builder()); - expect(inputValue.selection.baseOffset, 0); - expect(inputValue.selection.extentOffset, testValue.length); + expect(controller.selection.baseOffset, 0); + expect(controller.selection.extentOffset, testValue.length); // COPY should reset the selection. await tester.tap(find.text('COPY')); await tester.pumpWidget(builder()); - expect(inputValue.selection.isCollapsed, true); + expect(controller.selection.isCollapsed, true); // Tap again to bring back the menu. await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('e'))); await tester.pumpWidget(builder()); renderEditable = findRenderEditable(tester); - endpoints = renderEditable.getEndpointsForSelection(inputValue.selection); + endpoints = renderEditable.getEndpointsForSelection(controller.selection); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); await tester.pumpWidget(builder()); // PASTE right before the 'e'. await tester.tap(find.text('PASTE')); await tester.pumpWidget(builder()); - expect(inputValue.text, 'abc d${testValue}ef ghi'); + expect(controller.text, 'abc d${testValue}ef ghi'); }); testWidgets('Selection toolbar fades in', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final TextEditingController controller = new TextEditingController(); Widget builder() { - return new Overlay( - initialEntries: [ - new OverlayEntry( - builder: (BuildContext context) { - return new Center( - child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - onChanged: (InputValue value) { inputValue = value; } - ) - ) - ); - } - ) - ] - ); + return overlay(new Center( + child: new Material( + child: new TextField( + controller: controller, + ), + ), + )); } await tester.pumpWidget(builder()); @@ -412,7 +378,7 @@ void main() { await tester.pumpWidget(builder()); final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = renderEditable.getEndpointsForSelection( - inputValue.selection); + controller.selection); await tester.tapAt(endpoints[0].point + const Offset(1.0, 1.0)); await tester.pumpWidget(builder()); @@ -432,27 +398,26 @@ void main() { }); testWidgets('Multiline text will wrap up to maxLines', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final Key textFieldKey = new UniqueKey(); Widget builder(int maxLines) { return new Center( child: new Material( - child: new Input( - value: inputValue, - key: inputKey, + child: new TextField( + key: textFieldKey, style: const TextStyle(color: Colors.black, fontSize: 34.0), maxLines: maxLines, - hintText: 'Placeholder', - onChanged: (InputValue value) { inputValue = value; } - ) - ) + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + ), + ), ); } await tester.pumpWidget(builder(3)); - RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); final RenderBox inputBox = findInputBox(); final Size emptyInputSize = inputBox.size; @@ -487,29 +452,18 @@ void main() { }); testWidgets('Can drag handles to change selection in multiline', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final TextEditingController controller = new TextEditingController(); Widget builder() { - return new Overlay( - initialEntries: [ - new OverlayEntry( - builder: (BuildContext context) { - return new Center( - child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - style: const TextStyle(color: Colors.black, fontSize: 34.0), - maxLines: 3, - onChanged: (InputValue value) { inputValue = value; } - ) - ) - ); - } - ) - ] - ); + return overlay(new Center( + child: new Material( + child: new TextField( + controller: controller, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 3, + ), + ), + )); } await tester.pumpWidget(builder()); @@ -537,12 +491,12 @@ void main() { await gesture.up(); await tester.pump(); - expect(inputValue.selection.baseOffset, 76); - expect(inputValue.selection.extentOffset, 81); + expect(controller.selection.baseOffset, 76); + expect(controller.selection.extentOffset, 81); final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = renderEditable.getEndpointsForSelection( - inputValue.selection); + controller.selection); expect(endpoints.length, 2); // Drag the right handle to the third line, just after 'Third'. @@ -555,8 +509,8 @@ void main() { await gesture.up(); await tester.pumpWidget(builder()); - expect(inputValue.selection.baseOffset, 76); - expect(inputValue.selection.extentOffset, 108); + expect(controller.selection.baseOffset, 76); + expect(controller.selection.extentOffset, 108); // Drag the left handle to the first line, just after 'First'. handlePos = endpoints[0].point + const Offset(-1.0, 1.0); @@ -568,39 +522,30 @@ void main() { await gesture.up(); await tester.pumpWidget(builder()); - expect(inputValue.selection.baseOffset, 5); - expect(inputValue.selection.extentOffset, 108); + expect(controller.selection.baseOffset, 5); + expect(controller.selection.extentOffset, 108); await tester.tap(find.text('CUT')); await tester.pumpWidget(builder()); - expect(inputValue.selection.isCollapsed, true); - expect(inputValue.text, cutValue); + expect(controller.selection.isCollapsed, true); + expect(controller.text, cutValue); }, skip: Platform.isMacOS); // Skip due to https://github.com/flutter/flutter/issues/6961 testWidgets('Can scroll multiline input', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); - InputValue inputValue = InputValue.empty; + final Key textFieldKey = new UniqueKey(); + final TextEditingController controller = new TextEditingController(); Widget builder() { - return new Overlay( - initialEntries: [ - new OverlayEntry( - builder: (BuildContext context) { - return new Center( - child: new Material( - child: new Input( - value: inputValue, - key: inputKey, - style: const TextStyle(color: Colors.black, fontSize: 34.0), - maxLines: 2, - onChanged: (InputValue value) { inputValue = value; } - ) - ) - ); - } - ) - ] - ); + return overlay(new Center( + child: new Material( + child: new TextField( + key: textFieldKey, + controller: controller, + style: const TextStyle(color: Colors.black, fontSize: 34.0), + maxLines: 2, + ), + ), + )); } await tester.pumpWidget(builder()); @@ -610,7 +555,7 @@ void main() { await tester.pumpWidget(builder()); - RenderBox findInputBox() => tester.renderObject(find.byKey(inputKey)); + RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey)); final RenderBox inputBox = findInputBox(); // Check that the last line of text is not displayed. @@ -652,7 +597,7 @@ void main() { final RenderEditable renderEditable = findRenderEditable(tester); final List endpoints = renderEditable.getEndpointsForSelection( - inputValue.selection); + controller.selection); expect(endpoints.length, 2); // Drag the left handle to the first line, just after 'First'. @@ -675,17 +620,18 @@ void main() { }, skip: Platform.isMacOS); // Skip due to https://github.com/flutter/flutter/issues/6961 testWidgets('InputField smoke test', (WidgetTester tester) async { - InputValue inputValue = InputValue.empty; + String textFieldValue; Widget builder() { return new Center( child: new Material( - child: new InputField( - value: inputValue, - hintText: 'Placeholder', - onChanged: (InputValue value) { inputValue = value; } - ) - ) + child: new TextField( + decoration: null, + onChanged: (String value) { + textFieldValue = value; + }, + ), + ), ); } @@ -696,7 +642,7 @@ void main() { await tester.idle(); // Check that the onChanged event handler fired. - expect(inputValue.text, equals(testValue)); + expect(textFieldValue, equals(testValue)); return await tester.pumpWidget(builder()); } @@ -705,19 +651,20 @@ void main() { }); testWidgets('InputField with global key', (WidgetTester tester) async { - final GlobalKey inputFieldKey = new GlobalKey(debugLabel: 'inputFieldKey'); - InputValue inputValue = InputValue.empty; + final GlobalKey textFieldKey = new GlobalKey(debugLabel: 'textFieldKey'); + String textFieldValue; Widget builder() { return new Center( child: new Material( - child: new InputField( - key: inputFieldKey, - value: inputValue, - hintText: 'Placeholder', - onChanged: (InputValue value) { inputValue = value; } - ) - ) + child: new TextField( + key: textFieldKey, + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + onChanged: (String value) { textFieldValue = value; }, + ), + ), ); } @@ -728,7 +675,7 @@ void main() { await tester.idle(); // Check that the onChanged event handler fired. - expect(inputValue.text, equals(testValue)); + expect(textFieldValue, equals(testValue)); return await tester.pumpWidget(builder()); } @@ -736,9 +683,8 @@ void main() { checkText('Hello World'); }); - testWidgets('InputField with default hintStyle', (WidgetTester tester) async { - final InputValue inputValue = InputValue.empty; - final TextStyle textStyle = new TextStyle( + testWidgets('TextField with default hintStyle', (WidgetTester tester) async { + final TextStyle style = new TextStyle( color: Colors.pink[500], fontSize: 10.0, ); @@ -751,10 +697,11 @@ void main() { child: new Theme( data: themeData, child: new Material( - child: new InputField( - value: inputValue, - hintText: 'Placeholder', - style: textStyle, + child: new TextField( + decoration: new InputDecoration( + hintText: 'Placeholder', + ), + style: style, ), ), ), @@ -765,11 +712,10 @@ void main() { final Text hintText = tester.widget(find.text('Placeholder')); expect(hintText.style.color, themeData.hintColor); - expect(hintText.style.fontSize, textStyle.fontSize); + expect(hintText.style.fontSize, style.fontSize); }); - testWidgets('InputField with specified hintStyle', (WidgetTester tester) async { - final InputValue inputValue = InputValue.empty; + testWidgets('TextField with specified hintStyle', (WidgetTester tester) async { final TextStyle hintStyle = new TextStyle( color: Colors.pink[500], fontSize: 10.0, @@ -778,10 +724,11 @@ void main() { Widget builder() { return new Center( child: new Material( - child: new InputField( - value: inputValue, - hintText: 'Placeholder', - hintStyle: hintStyle, + child: new TextField( + decoration: new InputDecoration( + hintText: 'Placeholder', + hintStyle: hintStyle, + ), ), ), ); @@ -793,20 +740,24 @@ void main() { expect(hintText.style, hintStyle); }); - testWidgets('Input label text animates', (WidgetTester tester) async { - final GlobalKey inputKey = new GlobalKey(); + testWidgets('TextField label text animates', (WidgetTester tester) async { + final Key secondKey = new UniqueKey(); Widget innerBuilder() { return new Center( child: new Material( child: new Column( children: [ - new Input( - labelText: 'First', + new TextField( + decoration: new InputDecoration( + labelText: 'First', + ), ), - new Input( - key: inputKey, - labelText: 'Second', + new TextField( + key: secondKey, + decoration: new InputDecoration( + labelText: 'Second', + ), ), ], ), @@ -820,7 +771,7 @@ void main() { Point pos = tester.getTopLeft(find.text('Second')); // Focus the Input. The label should start animating upwards. - await tester.tap(find.byKey(inputKey)); + await tester.tap(find.byKey(secondKey)); await tester.idle(); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); @@ -839,10 +790,11 @@ void main() { await tester.pumpWidget( new Center( child: new Material( - child: new Input( - icon: new Icon(Icons.phone), - labelText: 'label', - value: InputValue.empty, + child: new TextField( + decoration: new InputDecoration( + icon: new Icon(Icons.phone), + labelText: 'label', + ), ), ), ), @@ -850,6 +802,6 @@ void main() { final double iconRight = tester.getTopRight(find.byType(Icon)).x; expect(iconRight, equals(tester.getTopLeft(find.text('label')).x)); - expect(iconRight, equals(tester.getTopLeft(find.byType(InputField)).x)); + expect(iconRight, equals(tester.getTopLeft(find.byType(EditableText)).x)); }); } diff --git a/packages/flutter/test/widgets/form_test.dart b/packages/flutter/test/widgets/form_test.dart index fb6119f347d..91ce9d127a2 100644 --- a/packages/flutter/test/widgets/form_test.dart +++ b/packages/flutter/test/widgets/form_test.dart @@ -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 formKey = new GlobalKey(); - 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 formKey = new GlobalKey(); - final GlobalKey> fieldKey = new GlobalKey>(); + final GlobalKey> fieldKey = new GlobalKey>(); // 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: [ - 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> inputKey = new GlobalKey>(); + final TextEditingController controller = new TextEditingController(text: initialValue); + final GlobalKey> inputKey = new GlobalKey>(); 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 formKey = new GlobalKey(); - 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'; } ), - ) - ) + ), + ), ); } diff --git a/packages/flutter_driver/lib/src/driver.dart b/packages/flutter_driver/lib/src/driver.dart index d40ab3f3868..23f63f41e8a 100644 --- a/packages/flutter_driver/lib/src/driver.dart +++ b/packages/flutter_driver/lib/src/driver.dart @@ -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 submitInputText(SerializableFinder finder, {Duration timeout}) async { final Map json = await _sendCommand(new SubmitInputText(finder, timeout: timeout)); return json['text']; diff --git a/packages/flutter_driver/lib/src/extension.dart b/packages/flutter_driver/lib/src/extension.dart index 6649ff4fd77..366daede884 100644 --- a/packages/flutter_driver/lib/src/extension.dart +++ b/packages/flutter_driver/lib/src/extension.dart @@ -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 _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 _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 _getText(Command command) async {