diff --git a/examples/flutter_gallery/lib/demo/expansion_panels_demo.dart b/examples/flutter_gallery/lib/demo/expansion_panels_demo.dart index a3b88fdbacc..675e1eb1560 100644 --- a/examples/flutter_gallery/lib/demo/expansion_panels_demo.dart +++ b/examples/flutter_gallery/lib/demo/expansion_panels_demo.dart @@ -189,23 +189,25 @@ class _ExpansionPanelsDemoState extends State { }); } - return new CollapsibleBody( - margin: const EdgeInsets.symmetric(horizontal: 16.0), - child: new Form( - child: new Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: new Input( - hintText: item.hint, - labelText: item.name, - value: new InputValue(text: item.value), - formField: new FormField( - setter: (String val) { item.value = val; } + return new Form( + child: new Builder( + builder: (BuildContext context) { + return new CollapsibleBody( + margin: const EdgeInsets.symmetric(horizontal: 16.0), + onSave: () { Form.of(context).save(); close(); }, + onCancel: () { Form.of(context).reset(); close(); }, + child: new Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: new InputFormField( + hintText: item.hint, + labelText: item.name, + initialValue: new InputValue(text: item.value), + onSaved: (InputValue val) { item.value = val.text; }, + ), ), - ), - ), - ), - onSave: close, - onCancel: close + ); + } + ) ); } ), @@ -221,54 +223,61 @@ class _ExpansionPanelsDemoState extends State { }); } - void changeLocation(_Location newLocation) { - setState(() { - item.value = newLocation; - }); - } - return new CollapsibleBody( - child: new Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Row( - mainAxisSize: MainAxisSize.min, - children: [ - new Radio<_Location>( - value: _Location.Bahamas, - groupValue: item.value, - onChanged: changeLocation - ), - new Text('Bahamas') - ] - ), - new Row( - mainAxisSize: MainAxisSize.min, - children: [ - new Radio<_Location>( - value: _Location.Barbados, - groupValue: item.value, - onChanged: changeLocation - ), - new Text('Barbados') - ] - ), - new Row( - mainAxisSize: MainAxisSize.min, - children: [ - new Radio<_Location>( - value: _Location.Bermuda, - groupValue: item.value, - onChanged: changeLocation - ), - new Text('Bermuda') - ] - ) - ] - ), - onSave: close, - onCancel: close + return new Form( + child: new Builder( + builder: (BuildContext context) { + return new CollapsibleBody( + onSave: () { Form.of(context).save(); close(); }, + onCancel: () { Form.of(context).reset(); close(); }, + child: new FormField<_Location>( + initialValue: item.value, + onSaved: (_Location result) { item.value = result; }, + builder: (FormFieldState<_Location> field) { + return new Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + new Row( + mainAxisSize: MainAxisSize.min, + children: [ + new Radio<_Location>( + value: _Location.Bahamas, + groupValue: field.value, + onChanged: field.onChanged, + ), + new Text('Bahamas') + ] + ), + new Row( + mainAxisSize: MainAxisSize.min, + children: [ + new Radio<_Location>( + value: _Location.Barbados, + groupValue: field.value, + onChanged: field.onChanged, + ), + new Text('Barbados') + ] + ), + new Row( + mainAxisSize: MainAxisSize.min, + children: [ + new Radio<_Location>( + value: _Location.Bermuda, + groupValue: field.value, + onChanged: field.onChanged, + ), + new Text('Bermuda') + ] + ) + ] + ); + } + ), + ); + } + ) ); } ), @@ -284,22 +293,30 @@ class _ExpansionPanelsDemoState extends State { }); } - return new CollapsibleBody( - child: new Slider( - value: item.value, - min: 0.0, - max: 100.0, - divisions: 5, - activeColor: Colors.orange[100 + (item.value * 5.0).round()], - label: '${item.value.round()}', - onChanged: (double value) { - setState(() { - item.value = value; - }); + return new Form( + child: new Builder( + builder: (BuildContext context) { + return new CollapsibleBody( + onSave: () { Form.of(context).save(); close(); }, + onCancel: () { Form.of(context).reset(); close(); }, + child: new FormField( + initialValue: item.value, + onSaved: (double value) { item.value = value; }, + builder: (FormFieldState field) { + return new Slider( + min: 0.0, + max: 100.0, + divisions: 5, + activeColor: Colors.orange[100 + (field.value * 5.0).round()], + label: '${field.value.round()}', + value: field.value, + onChanged: field.onChanged, + ); + }, + ), + ); } - ), - onSave: close, - onCancel: close + ) ); } ) diff --git a/examples/flutter_gallery/lib/demo/text_field_demo.dart b/examples/flutter_gallery/lib/demo/text_field_demo.dart index 8b95d28a770..9a3d87df3d3 100644 --- a/examples/flutter_gallery/lib/demo/text_field_demo.dart +++ b/examples/flutter_gallery/lib/demo/text_field_demo.dart @@ -30,37 +30,39 @@ class TextFieldDemoState extends State { )); } + GlobalKey _formKey = new GlobalKey(); + GlobalKey> _passwordFieldKey = new GlobalKey>(); void _handleSubmitted() { - // TODO(mpcomplete): Form could keep track of validation errors? - if (_validateName(person.name) != null || - _validatePhoneNumber(person.phoneNumber) != null || - _validatePassword(person.password) != null) { + FormState form = _formKey.currentState; + if (form.hasErrors) { showInSnackBar('Please fix the errors in red before submitting.'); } else { + form.save(); showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}'); } } - String _validateName(String value) { - if (value.isEmpty) + String _validateName(InputValue value) { + if (value.text.isEmpty) return 'Name is required.'; RegExp nameExp = new RegExp(r'^[A-za-z ]+$'); - if (!nameExp.hasMatch(value)) + if (!nameExp.hasMatch(value.text)) return 'Please enter only alphabetical characters.'; return null; } - String _validatePhoneNumber(String value) { + String _validatePhoneNumber(InputValue value) { RegExp phoneExp = new RegExp(r'^\d\d\d-\d\d\d\-\d\d\d\d$'); - if (!phoneExp.hasMatch(value)) + if (!phoneExp.hasMatch(value.text)) return '###-###-#### - Please enter a valid phone number.'; return null; } - String _validatePassword(String value) { - if (person.password == null || person.password.isEmpty) + String _validatePassword(InputValue value) { + FormFieldState passwordField = _passwordFieldKey.currentState; + if (passwordField.value == null || passwordField.value.text.isEmpty) return 'Please choose a password.'; - if (person.password != value) + if (passwordField.value.text != value.text) return 'Passwords don\'t match'; return null; } @@ -73,55 +75,57 @@ class TextFieldDemoState extends State { title: new Text('Text fields') ), body: new Form( + key: _formKey, child: new Block( padding: const EdgeInsets.symmetric(horizontal: 16.0), children: [ - new Input( - hintText: 'What do people call you?', - labelText: 'Name', - formField: new FormField( - // TODO(mpcomplete): replace with person#name= - setter: (String val) { person.name = val; }, - validator: _validateName - ) + // It's simpler to use an InputFormField, as below, but a FormField + // that builds an Input is equivalent. + new FormField( + initialValue: InputValue.empty, + onSaved: (InputValue val) { person.name = val.text; }, + validator: _validateName, + builder: (FormFieldState field) { + return new Input( + hintText: 'What do people call you?', + labelText: 'Name', + value: field.value, + onChanged: field.onChanged, + errorText: field.errorText + ); + }, ), - new Input( + new InputFormField( hintText: 'Where can we reach you?', labelText: 'Phone Number', keyboardType: TextInputType.phone, - formField: new FormField( - setter: (String val) { person.phoneNumber = val; }, - validator: _validatePhoneNumber - ) + onSaved: (InputValue val) { person.phoneNumber = val.text; }, + validator: _validatePhoneNumber, ), - new Input( + new InputFormField( hintText: 'Tell us about yourself (optional)', labelText: 'Life story', maxLines: 3, - formField: new FormField() ), new Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ new Flexible( - child: new Input( + child: new InputFormField( + key: _passwordFieldKey, hintText: 'How do you log in?', labelText: 'New Password', hideText: true, - formField: new FormField( - setter: (String val) { person.password = val; } - ) + onSaved: (InputValue val) { person.password = val.text; } ) ), new SizedBox(width: 16.0), new Flexible( - child: new Input( + child: new InputFormField( hintText: 'How do you log in?', labelText: 'Re-type Password', hideText: true, - formField: new FormField( - validator: _validatePassword - ) + validator: _validatePassword, ) ) ] diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index 258057def4b..e60cbec3607 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -20,13 +20,9 @@ export 'package:flutter/services.dart' show TextInputType; /// /// Requires one of its ancestors to be a [Material] widget. /// -/// If the [Input] has a [Form] ancestor, the [formField] property must -/// be specified. In this case, the [Input] keeps track of the value of -/// the [Input] field automatically, and the initial value can be specified -/// using the [value] property. -/// -/// If the [Input] does not have a [Form] ancestor, then the [value] -/// must be updated each time the [onChanged] callback is invoked. +/// 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. /// /// See also: /// @@ -39,8 +35,9 @@ class Input extends StatefulWidget { /// Creates a text input field. /// /// By default, the input uses a keyboard appropriate for text entry. - /// - /// The [formField] argument is required if the [Input] has an ancestor [Form]. + // + // Note: If you change this constructor signature, please also update + // InputFormField below. Input({ Key key, this.value, @@ -54,17 +51,12 @@ class Input extends StatefulWidget { this.isDense: false, this.autofocus: false, this.maxLines: 1, - this.formField, this.onChanged, this.onSubmitted, }) : super(key: key); - /// The text of the input field. - /// - /// If the [Input] is in a [Form], this is the initial value only. - /// - /// Otherwise, this is the current value, and must be updated every - /// time [onChanged] is called. + /// 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. @@ -86,9 +78,6 @@ class Input extends StatefulWidget { final String hintText; /// Text to show when the input text is invalid. - /// - /// If this is set, then the [formField]'s [FormField.validator], if any, is - /// ignored. final String errorText; /// The style to use for the text being edited. @@ -111,27 +100,12 @@ class Input extends StatefulWidget { /// horizontally instead. final int maxLines; - /// The [Form] entry for this input control. Required if the input is in a [Form]. - /// Ignored otherwise. - /// - /// Putting an Input in a [Form] means the Input will keep track of its own value, - /// using the [value] property only as the field's initial value. It also means - /// that when any field in the [Form] changes, all the widgets in the form will be - /// rebuilt, so that each field's [FormField.validator] callback can be reevaluated. - final FormField formField; - /// Called when the text being edited changes. /// - /// If the [Input] is not in a [Form], the [value] must be updated each time [onChanged] - /// is invoked. (If there is a [Form], then the value is tracked in the [formField], and - /// this callback is purely advisory.) - /// - /// If the [Input] is in a [Form], this is called after the [formField] is updated. + /// 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. - /// - /// If the [Input] is in a [Form], this is called after the [formField] is notified. final ValueChanged onSubmitted; @override @@ -146,34 +120,15 @@ class _InputState extends State { GlobalKey get focusKey => config.key is GlobalKey ? config.key : _rawInputKey; - // Optional state to retain if we are inside a Form widget. - _FormFieldData _formData; - - @override - void dispose() { - _formData?.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); ThemeData themeData = Theme.of(context); BuildContext focusContext = focusKey.currentContext; bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus); - if (_formData == null) { - _formData = _FormFieldData.maybeCreate(context, this); - } else { - _formData = _formData.maybeDispose(context); - } - InputValue value = _formData?.value ?? config.value ?? InputValue.empty; - ValueChanged onChanged = _formData?.onChanged ?? config.onChanged; - ValueChanged onSubmitted = _formData?.onSubmitted ?? config.onSubmitted; + InputValue value = config.value ?? InputValue.empty; String errorText = config.errorText; - if (errorText == null && config.formField != null && config.formField.validator != null) - errorText = config.formField.validator(value.text); - TextStyle textStyle = config.style ?? themeData.textTheme.subhead; Color activeColor = themeData.hintColor; if (focused) { @@ -263,8 +218,8 @@ class _InputState extends State { selectionControls: materialTextSelectionControls, platform: Theme.of(context).platform, keyboardType: config.keyboardType, - onChanged: onChanged, - onSubmitted: onSubmitted, + onChanged: config.onChanged, + onSubmitted: config.onSubmitted, ) )); @@ -312,68 +267,44 @@ class _InputState extends State { } } -// _FormFieldData is a helper class for _InputState for when the Input -// is in a Form. -// -// An instance is created when the Input is put in a Form, and lives -// until the Input is taken placed somewhere without a Form. (If the -// Input is moved from one Form to another, the same _FormFieldData is -// used for both forms). -// -// The _FormFieldData stores the value of the Input. Without a Form, -// the Input is essentially stateless. - -class _FormFieldData { - _FormFieldData(this.inputState) { - assert(field != null); - value = inputState.config.value ?? new InputValue(); - } - - final _InputState inputState; - InputValue value; - - FormField get field => inputState.config.formField; - - static _FormFieldData maybeCreate(BuildContext context, _InputState inputState) { - // Only create a _FormFieldData if this Input is a descendent of a Form. - if (FormScope.of(context) != null) - return new _FormFieldData(inputState); - return null; - } - - _FormFieldData maybeDispose(BuildContext context) { - if (FormScope.of(context) != null) - return this; - dispose(); - return null; - } - - void dispose() { - value = null; - } - - void onChanged(InputValue value) { - assert(value != null); - assert(field != null); - FormScope scope = FormScope.of(inputState.context); - assert(scope != null); - this.value = value; - if (field.setter != null) - field.setter(value.text); - if (inputState.config.onChanged != null) - inputState.config.onChanged(value); - scope.onFieldChanged(); - } - - void onSubmitted(InputValue value) { - assert(value != null); - assert(field != null); - FormScope scope = FormScope.of(inputState.context); - assert(scope != null); - if (scope.form.onSubmitted != null) - scope.form.onSubmitted(); - if (inputState.config.onSubmitted != null) - inputState.config.onSubmitted(value); - scope.onFieldChanged(); - } +/// A [FormField] that contains an [Input]. +class InputFormField extends FormField { + InputFormField({ + Key key, + GlobalKey focusKey, + TextInputType keyboardType: TextInputType.text, + Icon icon, + String labelText, + String hintText, + TextStyle style, + bool hideText: false, + bool isDense: false, + bool autofocus: false, + int maxLines: 1, + InputValue initialValue: InputValue.empty, + FormFieldSetter onSaved, + FormFieldValidator validator, + }) : super( + key: key, + initialValue: initialValue, + onSaved: onSaved, + validator: validator, + builder: (FormFieldState field) { + return new Input( + key: focusKey, + keyboardType: keyboardType, + icon: icon, + labelText: labelText, + hintText: hintText, + style: style, + hideText: hideText, + isDense: isDense, + autofocus: autofocus, + maxLines: maxLines, + value: field.value, + onChanged: field.onChanged, + errorText: field.errorText, + ); + }, + ); } diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart index 81546fe3820..0afbd1c47a1 100644 --- a/packages/flutter/lib/src/widgets/form.dart +++ b/packages/flutter/lib/src/widgets/form.dart @@ -4,7 +4,6 @@ import 'package:meta/meta.dart'; -import 'basic.dart'; import 'framework.dart'; /// A container for grouping together multiple form field widgets (e.g. @@ -16,79 +15,94 @@ class Form extends StatefulWidget { Form({ Key key, @required this.child, - this.onSubmitted }) : super(key: key) { assert(child != null); } - /// Called when the input is accepted anywhere on the form. - final VoidCallback onSubmitted; + /// Returns the closest [FormState] which encloses the given context. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// FormState form = Form.of(context); + /// form.save(); + /// ``` + static FormState of(BuildContext context) { + _FormScope scope = context.inheritFromWidgetOfExactType(_FormScope); + return scope?._formState; + } /// Root of the widget hierarchy that contains this form. final Widget child; @override - _FormState createState() => new _FormState(); + FormState createState() => new FormState(); } -class _FormState extends State
{ - int generation = 0; +class FormState extends State { + int _generation = 0; + Set> _fields = new Set>(); - void _onFieldChanged() { + /// Called when a form field has changed. This will cause all form fields + /// to rebuild, useful if form fields have interdependencies. + void _fieldDidChange() { setState(() { - ++generation; + ++_generation; }); } + void _register(FormFieldState field) { + _fields.add(field); + } + + void _unregister(FormFieldState field) { + _fields.remove(field); + } + @override Widget build(BuildContext context) { - return new FormScope._( + return new _FormScope( formState: this, - generation: generation, + generation: _generation, child: config.child ); } + + /// Saves every FormField that is a descendant of this Form. + void save() { + for (FormFieldState field in _fields) + field.save(); + } + + /// Resets every FormField that is a descendant of this Form back to its + /// initialState. + void reset() { + for (FormFieldState field in _fields) + field.reset(); + _fieldDidChange(); + } + + /// Returns true if any descendant FormField has an error, false otherwise. + bool get hasErrors { + for (FormFieldState field in _fields) { + if (field.hasError) + return true; + } + return false; + } } -/// Signature for validating a form field. -typedef String FormFieldValidator(T value); - -/// Signature for being notified when a form field changes value. -typedef void FormFieldSetter(T newValue); - -/// Identifying information for form controls. -class FormField { - /// Creates identifying information for form controls - FormField({ - this.setter, - this.validator - }); - - /// An optional method to call with the new value when the form field changes. - final FormFieldSetter setter; - - /// An optional method that validates an input. Returns an error string to - /// display if the input is invalid, or null otherwise. - final FormFieldValidator validator; -} - -/// A widget that establishes a scope for a [Form]. -/// -/// Cannot be created directly. Instead, create a [Form] widget, which builds -/// a [FormScope]. -/// -/// Useful for locating the closest enclosing [Form]. -class FormScope extends InheritedWidget { - FormScope._({ +class _FormScope extends InheritedWidget { + _FormScope({ Key key, Widget child, - _FormState formState, + FormState formState, int generation }) : _formState = formState, _generation = generation, super(key: key, child: child); - final _FormState _formState; + final FormState _formState; /// Incremented every time a form field has changed. This lets us know when /// to rebuild the form. @@ -97,22 +111,117 @@ class FormScope extends InheritedWidget { /// The [Form] associated with this widget. Form get form => _formState.config; - /// The closest [FormScope] encloses the given context. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// FormScope form = FormScope.of(context); - /// ``` - static FormScope of(BuildContext context) { - return context.inheritFromWidgetOfExactType(FormScope); + @override + bool updateShouldNotify(_FormScope old) => _generation != old._generation; +} + +/// Signature for validating a form field. +typedef String FormFieldValidator(T value); + +/// Signature for being notified when a form field changes value. +typedef void FormFieldSetter(T newValue); + +/// Signature for building the widget representing the form field. +typedef Widget FormFieldBuilder(FormFieldState field); + +/// A single form field. This widget maintains the current state of the form +/// field, so that updates and validation errors are visually reflected in the +/// UI. +/// +/// When used inside a [Form], you can use methods on [FormState] to query or +/// manipulate the form data as a whole. For example, calling [FormState.save] +/// will invoke each [FormField]'s [onSaved] callback in turn. +/// +/// Use a [GlobalKey] with [FormField] if you want to retrieve its current +/// state, for example if you want one form field to depend on another. +/// +/// See also: [Form], [InputFormField] +class FormField extends StatefulWidget { + FormField({ + Key key, + @required this.builder, + this.onSaved, + this.validator, + this.initialValue, + }) : super(key: key) { + assert(builder != null); } - /// Use this to notify the Form that a form field has changed. This will - /// cause all form fields to rebuild, useful if form fields have - /// interdependencies. - void onFieldChanged() => _formState._onFieldChanged(); + /// An optional method to call with the final value when the form is saved via + /// Form.save(). + final FormFieldSetter onSaved; + + /// An optional method that validates an input. Returns an error string to + /// display if the input is invalid, or null otherwise. + final FormFieldValidator validator; + + /// Function that returns the widget representing this form field. It is + /// passed the form field state as input, containing the current value and + /// validation state of this field. + final FormFieldBuilder builder; + + /// An optional value to initialize the form field to, or null otherwise. + final T initialValue; @override - bool updateShouldNotify(FormScope old) => _generation != old._generation; + FormFieldState createState() => new FormFieldState(); +} + +class FormFieldState extends State> { + T _value; + String _errorText; + + /// The current value of the form field. + T get value => _value; + + /// The current validation error returned by [FormField]'s [validator] + /// callback, or null if no errors. + String get errorText => _errorText; + + /// True if this field has any validation errors. + bool get hasError => _errorText != null; + + /// Calls the [FormField]'s onSaved method with the current value. + void save() { + if (config.onSaved != null) + config.onSaved(value); + } + + /// Resets the field to its initial value. + void reset() { + setState(() { + _value = config.initialValue; + _errorText = null; + }); + } + + /// Updates this field's state to the new value. Useful for responding to + /// child widget changes, e.g. [Slider]'s onChanged argument. + void onChanged(T value) { + setState(() { + _value = value; + }); + Form.of(context)?._fieldDidChange(); + } + + @override + void initState() { + super.initState(); + _value = config.initialValue; + } + + @override + void deactivate() { + Form.of(context)?._unregister(this); + super.deactivate(); + } + + @override + Widget build(BuildContext context) { + if (config.validator != null) + _errorText = config.validator(_value); + + Form.of(context)?._register(this); + return config.builder(this); + } } diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index e2b6fa2cd7a..6f6bfdb4849 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -1453,6 +1453,18 @@ abstract class BuildContext { /// ancestor is added or removed). InheritedWidget inheritFromWidgetOfExactType(Type targetType); + /// Obtains the element corresponding to the nearest widget of the given type, + /// which must be the type of a concrete [InheritedWidget] subclass. + /// + /// Calling this method is O(1) with a small constant factor. + /// + /// This method does not establish a relationship with the target in the way + /// that [inheritFromWidgetOfExactType] does. It is normally used by such + /// widgets to obtain their corresponding [InheritedElement] object so that they + /// can call [InheritedElement.dispatchDependenciesChanged] to actually + /// notify the widgets that _did_ register such a relationship. + InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType); + /// Returns the nearest ancestor widget of the given type, which must be the /// type of a concrete [Widget] subclass. /// @@ -2450,6 +2462,12 @@ abstract class Element implements BuildContext { return null; } + @override + InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType) { + InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[targetType]; + return ancestor; + } + void _updateInheritance() { assert(_active); _inheritedWidgets = _parent?._inheritedWidgets; @@ -3166,8 +3184,9 @@ class InheritedElement extends ProxyElement { /// [InheritedElement] calls this function if [InheritedWidget.updateShouldNotify] /// returns true. Subclasses of [InheritedElement] might wish to call this /// function at other times if their inherited information changes outside of - /// the build phase. - @protected + /// the build phase. [InheritedWidget] subclasses can also call this directly + /// by first obtaining their [InheritedElement] using + /// [BuildContext.ancestorInheritedElementForWidgetOfExactType]. void dispatchDependenciesChanged() { for (Element dependent in _dependents) { assert(() { diff --git a/packages/flutter/test/widget/form_test.dart b/packages/flutter/test/widget/form_test.dart index 5c465b21c3c..050b5a59d00 100644 --- a/packages/flutter/test/widget/form_test.dart +++ b/packages/flutter/test/widget/form_test.dart @@ -14,17 +14,17 @@ void main() { mockTextInput.enterText(text); } - testWidgets('Setter callback is called', (WidgetTester tester) async { + testWidgets('onSaved callback is called', (WidgetTester tester) async { + GlobalKey formKey = new GlobalKey(); String fieldValue; Widget builder() { return new Center( child: new Material( child: new Form( - child: new Input( - formField: new FormField( - setter: (String value) { fieldValue = value; } - ) + key: formKey, + child: new InputFormField( + onSaved: (InputValue value) { fieldValue = value.text; } ) ) ) @@ -38,6 +38,7 @@ void main() { Future checkText(String testValue) async { enterText(testValue); await tester.idle(); + formKey.currentState.save(); // pump'ing is unnecessary because callback happens regardless of frames expect(fieldValue, equals(testValue)); } @@ -48,17 +49,15 @@ void main() { testWidgets('Validator sets the error text', (WidgetTester tester) async { GlobalKey inputKey = new GlobalKey(); - String errorText(String input) => input + '/error'; + String errorText(InputValue input) => input.text + '/error'; Widget builder() { return new Center( child: new Material( child: new Form( - child: new Input( + child: new InputFormField( key: inputKey, - formField: new FormField( - validator: errorText - ) + validator: errorText ) ) ) @@ -72,7 +71,7 @@ void main() { await tester.idle(); await tester.pump(); // Check for a new Text widget with our error text. - expect(find.text(errorText(testValue)), findsOneWidget); + expect(find.text(errorText(new InputValue(text: testValue))), findsOneWidget); } await checkErrorText('Test'); @@ -80,31 +79,29 @@ void main() { }); testWidgets('Multiple Inputs communicate', (WidgetTester tester) async { - GlobalKey inputKey = new GlobalKey(); + GlobalKey formKey = new GlobalKey(); + GlobalKey> fieldKey = new GlobalKey>(); + GlobalKey inputFocusKey = new GlobalKey(); GlobalKey focusKey = new GlobalKey(); - // Input 1's text value. - String fieldValue; // Input 2's validator depends on a input 1's value. - String errorText(String input) => fieldValue.toString() + '/error'; + String errorText(InputValue input) => fieldKey.currentState.value?.text.toString() + '/error'; Widget builder() { return new Center( child: new Material( child: new Form( + key: formKey, child: new Focus( key: focusKey, child: new Block( children: [ - new Input( - key: inputKey, - formField: new FormField( - setter: (String value) { fieldValue = value; } - ) + new InputFormField( + autofocus: true, + key: fieldKey, + focusKey: inputFocusKey, ), - new Input( - formField: new FormField( - validator: errorText - ) + new InputFormField( + validator: errorText ) ] ) @@ -115,18 +112,16 @@ void main() { } await tester.pumpWidget(builder()); - Focus.moveTo(inputKey); await tester.pump(); + Focus.moveTo(inputFocusKey); Future checkErrorText(String testValue) async { enterText(testValue); await tester.idle(); await tester.pump(); - expect(fieldValue, equals(testValue)); - // Check for a new Text widget with our error text. - expect(find.text(errorText(testValue)), findsOneWidget); + expect(find.text(testValue + '/error'), findsOneWidget); return null; } @@ -136,20 +131,18 @@ void main() { testWidgets('Provide initial value to input', (WidgetTester tester) async { String initialValue = 'hello'; - String currentValue; + GlobalKey> inputKey = new GlobalKey>(); Widget builder() { return new Center( - child: new Material( - child: new Form( - child: new Input( - value: new InputValue(text: initialValue), - formField: new FormField( - setter: (String value) { currentValue = value; } - ) - ) - ) + child: new Material( + child: new Form( + child: new InputFormField( + key: inputKey, + initialValue: new InputValue(text: initialValue), + ) ) + ) ); } @@ -164,12 +157,63 @@ void main() { expect(editableText.config.value.text, equals(initialValue)); // sanity check, make sure we can still edit the text and everything updates - expect(currentValue, isNull); + expect(inputKey.currentState.value.text, equals(initialValue)); enterText('world'); await tester.idle(); - expect(currentValue, equals('world')); await tester.pump(); + expect(inputKey.currentState.value.text, equals('world')); expect(editableText.config.value.text, equals('world')); + }); + testWidgets('No crash when a FormField is removed from the tree', (WidgetTester tester) async { + GlobalKey formKey = new GlobalKey(); + GlobalKey fieldKey = new GlobalKey(); + String fieldValue; + + Widget builder(bool remove) { + return new Center( + child: new Material( + child: new Form( + key: formKey, + child: remove ? + new Container() : + new InputFormField( + key: fieldKey, + autofocus: true, + onSaved: (InputValue value) { fieldValue = value.text; }, + validator: (InputValue value) { return value.text.isEmpty ? null : 'yes'; } + ) + ) + ) + ); + } + + await tester.pumpWidget(builder(false)); + await tester.pump(); + + expect(fieldValue, isNull); + expect(formKey.currentState.hasErrors, isFalse); + + enterText('Test'); + await tester.idle(); + await tester.pumpWidget(builder(false)); + + // Form wasn't saved, but validator runs immediately. + expect(fieldValue, null); + expect(formKey.currentState.hasErrors, isTrue); + + formKey.currentState.save(); + + // Now fieldValue is saved. + expect(fieldValue, 'Test'); + expect(formKey.currentState.hasErrors, isTrue); + + // Now remove the field with an error. + await tester.pumpWidget(builder(true)); + + // Reset the form. Should not crash. + formKey.currentState.reset(); + formKey.currentState.save(); + expect(formKey.currentState.hasErrors, isFalse); }); }