Commit 113991da authored by Matt Perry's avatar Matt Perry Committed by GitHub

Rethink Forms. (#6569)

FormField is now a widget that can contain any type of field. Input no
longer has special code to handle form fields. Instead, there is a
helper widget InputFormField for using an Input inside a FormField.

Fixes https://github.com/flutter/flutter/issues/6097 and based on
feedback from the same.
parent 43b4fc90
...@@ -189,23 +189,25 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> { ...@@ -189,23 +189,25 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
}); });
} }
return new CollapsibleBody( return new Form(
margin: const EdgeInsets.symmetric(horizontal: 16.0), child: new Builder(
child: new Form( builder: (BuildContext context) {
child: new Padding( return new CollapsibleBody(
padding: const EdgeInsets.symmetric(horizontal: 16.0), margin: const EdgeInsets.symmetric(horizontal: 16.0),
child: new Input( onSave: () { Form.of(context).save(); close(); },
hintText: item.hint, onCancel: () { Form.of(context).reset(); close(); },
labelText: item.name, child: new Padding(
value: new InputValue(text: item.value), padding: const EdgeInsets.symmetric(horizontal: 16.0),
formField: new FormField<String>( child: new InputFormField(
setter: (String val) { item.value = val; } 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<ExpasionPanelsDemo> { ...@@ -221,54 +223,61 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
}); });
} }
void changeLocation(_Location newLocation) {
setState(() {
item.value = newLocation;
});
}
return new CollapsibleBody( return new Form(
child: new Column( child: new Builder(
mainAxisSize: MainAxisSize.min, builder: (BuildContext context) {
crossAxisAlignment: CrossAxisAlignment.start, return new CollapsibleBody(
children: <Widget>[ onSave: () { Form.of(context).save(); close(); },
new Row( onCancel: () { Form.of(context).reset(); close(); },
mainAxisSize: MainAxisSize.min, child: new FormField<_Location>(
children: <Widget>[ initialValue: item.value,
new Radio<_Location>( onSaved: (_Location result) { item.value = result; },
value: _Location.Bahamas, builder: (FormFieldState<_Location> field) {
groupValue: item.value, return new Column(
onChanged: changeLocation mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.start,
new Text('Bahamas') children: <Widget>[
] new Row(
), mainAxisSize: MainAxisSize.min,
new Row( children: <Widget>[
mainAxisSize: MainAxisSize.min, new Radio<_Location>(
children: <Widget>[ value: _Location.Bahamas,
new Radio<_Location>( groupValue: field.value,
value: _Location.Barbados, onChanged: field.onChanged,
groupValue: item.value, ),
onChanged: changeLocation new Text('Bahamas')
), ]
new Text('Barbados') ),
] new Row(
), mainAxisSize: MainAxisSize.min,
new Row( children: <Widget>[
mainAxisSize: MainAxisSize.min, new Radio<_Location>(
children: <Widget>[ value: _Location.Barbados,
new Radio<_Location>( groupValue: field.value,
value: _Location.Bermuda, onChanged: field.onChanged,
groupValue: item.value, ),
onChanged: changeLocation new Text('Barbados')
), ]
new Text('Bermuda') ),
] new Row(
) mainAxisSize: MainAxisSize.min,
] children: <Widget>[
), new Radio<_Location>(
onSave: close, value: _Location.Bermuda,
onCancel: close groupValue: field.value,
onChanged: field.onChanged,
),
new Text('Bermuda')
]
)
]
);
}
),
);
}
)
); );
} }
), ),
...@@ -284,22 +293,30 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> { ...@@ -284,22 +293,30 @@ class _ExpansionPanelsDemoState extends State<ExpasionPanelsDemo> {
}); });
} }
return new CollapsibleBody( return new Form(
child: new Slider( child: new Builder(
value: item.value, builder: (BuildContext context) {
min: 0.0, return new CollapsibleBody(
max: 100.0, onSave: () { Form.of(context).save(); close(); },
divisions: 5, onCancel: () { Form.of(context).reset(); close(); },
activeColor: Colors.orange[100 + (item.value * 5.0).round()], child: new FormField<double>(
label: '${item.value.round()}', initialValue: item.value,
onChanged: (double value) { onSaved: (double value) { item.value = value; },
setState(() { builder: (FormFieldState<double> field) {
item.value = value; 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
); );
} }
) )
......
...@@ -30,37 +30,39 @@ class TextFieldDemoState extends State<TextFieldDemo> { ...@@ -30,37 +30,39 @@ class TextFieldDemoState extends State<TextFieldDemo> {
)); ));
} }
GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
GlobalKey<FormFieldState<InputValue>> _passwordFieldKey = new GlobalKey<FormFieldState<InputValue>>();
void _handleSubmitted() { void _handleSubmitted() {
// TODO(mpcomplete): Form could keep track of validation errors? FormState form = _formKey.currentState;
if (_validateName(person.name) != null || if (form.hasErrors) {
_validatePhoneNumber(person.phoneNumber) != null ||
_validatePassword(person.password) != null) {
showInSnackBar('Please fix the errors in red before submitting.'); showInSnackBar('Please fix the errors in red before submitting.');
} else { } else {
form.save();
showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}'); showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}');
} }
} }
String _validateName(String value) { String _validateName(InputValue value) {
if (value.isEmpty) if (value.text.isEmpty)
return 'Name is required.'; return 'Name is required.';
RegExp nameExp = new RegExp(r'^[A-za-z ]+$'); RegExp nameExp = new RegExp(r'^[A-za-z ]+$');
if (!nameExp.hasMatch(value)) if (!nameExp.hasMatch(value.text))
return 'Please enter only alphabetical characters.'; return 'Please enter only alphabetical characters.';
return null; 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$'); 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 '###-###-#### - Please enter a valid phone number.';
return null; return null;
} }
String _validatePassword(String value) { String _validatePassword(InputValue value) {
if (person.password == null || person.password.isEmpty) FormFieldState<InputValue> passwordField = _passwordFieldKey.currentState;
if (passwordField.value == null || passwordField.value.text.isEmpty)
return 'Please choose a password.'; return 'Please choose a password.';
if (person.password != value) if (passwordField.value.text != value.text)
return 'Passwords don\'t match'; return 'Passwords don\'t match';
return null; return null;
} }
...@@ -73,55 +75,57 @@ class TextFieldDemoState extends State<TextFieldDemo> { ...@@ -73,55 +75,57 @@ class TextFieldDemoState extends State<TextFieldDemo> {
title: new Text('Text fields') title: new Text('Text fields')
), ),
body: new Form( body: new Form(
key: _formKey,
child: new Block( child: new Block(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
children: <Widget>[ children: <Widget>[
new Input( // It's simpler to use an InputFormField, as below, but a FormField
hintText: 'What do people call you?', // that builds an Input is equivalent.
labelText: 'Name', new FormField<InputValue>(
formField: new FormField<String>( initialValue: InputValue.empty,
// TODO(mpcomplete): replace with person#name= onSaved: (InputValue val) { person.name = val.text; },
setter: (String val) { person.name = val; }, validator: _validateName,
validator: _validateName builder: (FormFieldState<InputValue> 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?', hintText: 'Where can we reach you?',
labelText: 'Phone Number', labelText: 'Phone Number',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
formField: new FormField<String>( onSaved: (InputValue val) { person.phoneNumber = val.text; },
setter: (String val) { person.phoneNumber = val; }, validator: _validatePhoneNumber,
validator: _validatePhoneNumber
)
), ),
new Input( new InputFormField(
hintText: 'Tell us about yourself (optional)', hintText: 'Tell us about yourself (optional)',
labelText: 'Life story', labelText: 'Life story',
maxLines: 3, maxLines: 3,
formField: new FormField<String>()
), ),
new Row( new Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
new Flexible( new Flexible(
child: new Input( child: new InputFormField(
key: _passwordFieldKey,
hintText: 'How do you log in?', hintText: 'How do you log in?',
labelText: 'New Password', labelText: 'New Password',
hideText: true, hideText: true,
formField: new FormField<String>( onSaved: (InputValue val) { person.password = val.text; }
setter: (String val) { person.password = val; }
)
) )
), ),
new SizedBox(width: 16.0), new SizedBox(width: 16.0),
new Flexible( new Flexible(
child: new Input( child: new InputFormField(
hintText: 'How do you log in?', hintText: 'How do you log in?',
labelText: 'Re-type Password', labelText: 'Re-type Password',
hideText: true, hideText: true,
formField: new FormField<String>( validator: _validatePassword,
validator: _validatePassword
)
) )
) )
] ]
......
...@@ -20,13 +20,9 @@ export 'package:flutter/services.dart' show TextInputType; ...@@ -20,13 +20,9 @@ export 'package:flutter/services.dart' show TextInputType;
/// ///
/// Requires one of its ancestors to be a [Material] widget. /// Requires one of its ancestors to be a [Material] widget.
/// ///
/// If the [Input] has a [Form] ancestor, the [formField] property must /// The [value] field must be updated each time the [onChanged] callback is
/// be specified. In this case, the [Input] keeps track of the value of /// invoked. Be sure to include the full [value] provided by the [onChanged]
/// the [Input] field automatically, and the initial value can be specified /// callback, or information like the current selection will be lost.
/// 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.
/// ///
/// See also: /// See also:
/// ///
...@@ -39,8 +35,9 @@ class Input extends StatefulWidget { ...@@ -39,8 +35,9 @@ class Input extends StatefulWidget {
/// Creates a text input field. /// Creates a text input field.
/// ///
/// By default, the input uses a keyboard appropriate for text entry. /// 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({ Input({
Key key, Key key,
this.value, this.value,
...@@ -54,17 +51,12 @@ class Input extends StatefulWidget { ...@@ -54,17 +51,12 @@ class Input extends StatefulWidget {
this.isDense: false, this.isDense: false,
this.autofocus: false, this.autofocus: false,
this.maxLines: 1, this.maxLines: 1,
this.formField,
this.onChanged, this.onChanged,
this.onSubmitted, this.onSubmitted,
}) : super(key: key); }) : super(key: key);
/// The text of the input field. /// The current state of text of the input field. This includes the selected
/// /// text, if any, among other things.
/// 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.
final InputValue value; final InputValue value;
/// The type of keyboard to use for editing the text. /// The type of keyboard to use for editing the text.
...@@ -86,9 +78,6 @@ class Input extends StatefulWidget { ...@@ -86,9 +78,6 @@ class Input extends StatefulWidget {
final String hintText; final String hintText;
/// Text to show when the input text is invalid. /// 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; final String errorText;
/// The style to use for the text being edited. /// The style to use for the text being edited.
...@@ -111,27 +100,12 @@ class Input extends StatefulWidget { ...@@ -111,27 +100,12 @@ class Input extends StatefulWidget {
/// horizontally instead. /// horizontally instead.
final int maxLines; 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<String> formField;
/// Called when the text being edited changes. /// Called when the text being edited changes.
/// ///
/// If the [Input] is not in a [Form], the [value] must be updated each time [onChanged] /// The [value] must be updated each time [onChanged] is invoked.
/// 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.
final ValueChanged<InputValue> onChanged; final ValueChanged<InputValue> onChanged;
/// Called when the user indicates that they are done editing the text in the field. /// 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<InputValue> onSubmitted; final ValueChanged<InputValue> onSubmitted;
@override @override
...@@ -146,34 +120,15 @@ class _InputState extends State<Input> { ...@@ -146,34 +120,15 @@ class _InputState extends State<Input> {
GlobalKey get focusKey => config.key is GlobalKey ? config.key : _rawInputKey; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context)); assert(debugCheckHasMaterial(context));
ThemeData themeData = Theme.of(context); ThemeData themeData = Theme.of(context);
BuildContext focusContext = focusKey.currentContext; BuildContext focusContext = focusKey.currentContext;
bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus); bool focused = focusContext != null && Focus.at(focusContext, autofocus: config.autofocus);
if (_formData == null) { InputValue value = config.value ?? InputValue.empty;
_formData = _FormFieldData.maybeCreate(context, this);
} else {
_formData = _formData.maybeDispose(context);
}
InputValue value = _formData?.value ?? config.value ?? InputValue.empty;
ValueChanged<InputValue> onChanged = _formData?.onChanged ?? config.onChanged;
ValueChanged<InputValue> onSubmitted = _formData?.onSubmitted ?? config.onSubmitted;
String errorText = config.errorText; 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; TextStyle textStyle = config.style ?? themeData.textTheme.subhead;
Color activeColor = themeData.hintColor; Color activeColor = themeData.hintColor;
if (focused) { if (focused) {
...@@ -263,8 +218,8 @@ class _InputState extends State<Input> { ...@@ -263,8 +218,8 @@ class _InputState extends State<Input> {
selectionControls: materialTextSelectionControls, selectionControls: materialTextSelectionControls,
platform: Theme.of(context).platform, platform: Theme.of(context).platform,
keyboardType: config.keyboardType, keyboardType: config.keyboardType,
onChanged: onChanged, onChanged: config.onChanged,
onSubmitted: onSubmitted, onSubmitted: config.onSubmitted,
) )
)); ));
...@@ -312,68 +267,44 @@ class _InputState extends State<Input> { ...@@ -312,68 +267,44 @@ class _InputState extends State<Input> {
} }
} }
// _FormFieldData is a helper class for _InputState for when the Input /// A [FormField] that contains an [Input].
// is in a Form. class InputFormField extends FormField<InputValue> {
// InputFormField({
// An instance is created when the Input is put in a Form, and lives Key key,
// until the Input is taken placed somewhere without a Form. (If the GlobalKey focusKey,
// Input is moved from one Form to another, the same _FormFieldData is TextInputType keyboardType: TextInputType.text,
// used for both forms). Icon icon,
// String labelText,
// The _FormFieldData stores the value of the Input. Without a Form, String hintText,
// the Input is essentially stateless. TextStyle style,
bool hideText: false,
class _FormFieldData { bool isDense: false,
_FormFieldData(this.inputState) { bool autofocus: false,
assert(field != null); int maxLines: 1,
value = inputState.config.value ?? new InputValue(); InputValue initialValue: InputValue.empty,
} FormFieldSetter<InputValue> onSaved,
FormFieldValidator<InputValue> validator,
final _InputState inputState; }) : super(
InputValue value; key: key,
initialValue: initialValue,
FormField<String> get field => inputState.config.formField; onSaved: onSaved,
validator: validator,
static _FormFieldData maybeCreate(BuildContext context, _InputState inputState) { builder: (FormFieldState<InputValue> field) {
// Only create a _FormFieldData if this Input is a descendent of a Form. return new Input(
if (FormScope.of(context) != null) key: focusKey,
return new _FormFieldData(inputState); keyboardType: keyboardType,
return null; icon: icon,
} labelText: labelText,
hintText: hintText,
_FormFieldData maybeDispose(BuildContext context) { style: style,
if (FormScope.of(context) != null) hideText: hideText,
return this; isDense: isDense,
dispose(); autofocus: autofocus,
return null; maxLines: maxLines,
} value: field.value,
onChanged: field.onChanged,
void dispose() { errorText: field.errorText,
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();
}
} }
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'basic.dart';
import 'framework.dart'; import 'framework.dart';
/// A container for grouping together multiple form field widgets (e.g. /// A container for grouping together multiple form field widgets (e.g.
...@@ -16,79 +15,94 @@ class Form extends StatefulWidget { ...@@ -16,79 +15,94 @@ class Form extends StatefulWidget {
Form({ Form({
Key key, Key key,
@required this.child, @required this.child,
this.onSubmitted
}) : super(key: key) { }) : super(key: key) {
assert(child != null); assert(child != null);
} }
/// Called when the input is accepted anywhere on the form. /// Returns the closest [FormState] which encloses the given context.
final VoidCallback onSubmitted; ///
/// 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. /// Root of the widget hierarchy that contains this form.
final Widget child; final Widget child;
@override @override
_FormState createState() => new _FormState(); FormState createState() => new FormState();
} }
class _FormState extends State<Form> { class FormState extends State<Form> {
int generation = 0; int _generation = 0;
Set<FormFieldState<dynamic>> _fields = new Set<FormFieldState<dynamic>>();
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(() { setState(() {
++generation; ++_generation;
}); });
} }
void _register(FormFieldState<dynamic> field) {
_fields.add(field);
}
void _unregister(FormFieldState<dynamic> field) {
_fields.remove(field);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new FormScope._( return new _FormScope(
formState: this, formState: this,
generation: generation, generation: _generation,
child: config.child child: config.child
); );
} }
}
/// Signature for validating a form field.
typedef String FormFieldValidator<T>(T value);
/// Signature for being notified when a form field changes value.
typedef void FormFieldSetter<T>(T newValue);
/// Identifying information for form controls. /// Saves every FormField that is a descendant of this Form.
class FormField<T> { void save() {
/// Creates identifying information for form controls for (FormFieldState<dynamic> field in _fields)
FormField({ field.save();
this.setter, }
this.validator
});
/// An optional method to call with the new value when the form field changes. /// Resets every FormField that is a descendant of this Form back to its
final FormFieldSetter<T> setter; /// initialState.
void reset() {
for (FormFieldState<dynamic> field in _fields)
field.reset();
_fieldDidChange();
}
/// An optional method that validates an input. Returns an error string to /// Returns true if any descendant FormField has an error, false otherwise.
/// display if the input is invalid, or null otherwise. bool get hasErrors {
final FormFieldValidator<T> validator; for (FormFieldState<dynamic> field in _fields) {
if (field.hasError)
return true;
}
return false;
}
} }
/// A widget that establishes a scope for a [Form]. class _FormScope extends InheritedWidget {
/// _FormScope({
/// 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._({
Key key, Key key,
Widget child, Widget child,
_FormState formState, FormState formState,
int generation int generation
}) : _formState = formState, }) : _formState = formState,
_generation = generation, _generation = generation,
super(key: key, child: child); super(key: key, child: child);
final _FormState _formState; final FormState _formState;
/// Incremented every time a form field has changed. This lets us know when /// Incremented every time a form field has changed. This lets us know when
/// to rebuild the form. /// to rebuild the form.
...@@ -97,22 +111,117 @@ class FormScope extends InheritedWidget { ...@@ -97,22 +111,117 @@ class FormScope extends InheritedWidget {
/// The [Form] associated with this widget. /// The [Form] associated with this widget.
Form get form => _formState.config; Form get form => _formState.config;
/// The closest [FormScope] encloses the given context. @override
/// bool updateShouldNotify(_FormScope old) => _generation != old._generation;
/// Typical usage is as follows: }
///
/// ```dart /// Signature for validating a form field.
/// FormScope form = FormScope.of(context); typedef String FormFieldValidator<T>(T value);
/// ```
static FormScope of(BuildContext context) { /// Signature for being notified when a form field changes value.
return context.inheritFromWidgetOfExactType(FormScope); typedef void FormFieldSetter<T>(T newValue);
/// Signature for building the widget representing the form field.
typedef Widget FormFieldBuilder<T>(FormFieldState<T> 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<T> 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 /// An optional method to call with the final value when the form is saved via
/// cause all form fields to rebuild, useful if form fields have /// Form.save().
/// interdependencies. final FormFieldSetter<T> onSaved;
void onFieldChanged() => _formState._onFieldChanged();
/// An optional method that validates an input. Returns an error string to
/// display if the input is invalid, or null otherwise.
final FormFieldValidator<T> 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<T> builder;
/// An optional value to initialize the form field to, or null otherwise.
final T initialValue;
@override @override
bool updateShouldNotify(FormScope old) => _generation != old._generation; FormFieldState<T> createState() => new FormFieldState<T>();
}
class FormFieldState<T> extends State<FormField<T>> {
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);
}
} }
...@@ -1453,6 +1453,18 @@ abstract class BuildContext { ...@@ -1453,6 +1453,18 @@ abstract class BuildContext {
/// ancestor is added or removed). /// ancestor is added or removed).
InheritedWidget inheritFromWidgetOfExactType(Type targetType); 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 /// Returns the nearest ancestor widget of the given type, which must be the
/// type of a concrete [Widget] subclass. /// type of a concrete [Widget] subclass.
/// ///
...@@ -2450,6 +2462,12 @@ abstract class Element implements BuildContext { ...@@ -2450,6 +2462,12 @@ abstract class Element implements BuildContext {
return null; return null;
} }
@override
InheritedElement ancestorInheritedElementForWidgetOfExactType(Type targetType) {
InheritedElement ancestor = _inheritedWidgets == null ? null : _inheritedWidgets[targetType];
return ancestor;
}
void _updateInheritance() { void _updateInheritance() {
assert(_active); assert(_active);
_inheritedWidgets = _parent?._inheritedWidgets; _inheritedWidgets = _parent?._inheritedWidgets;
...@@ -3166,8 +3184,9 @@ class InheritedElement extends ProxyElement { ...@@ -3166,8 +3184,9 @@ class InheritedElement extends ProxyElement {
/// [InheritedElement] calls this function if [InheritedWidget.updateShouldNotify] /// [InheritedElement] calls this function if [InheritedWidget.updateShouldNotify]
/// returns true. Subclasses of [InheritedElement] might wish to call this /// returns true. Subclasses of [InheritedElement] might wish to call this
/// function at other times if their inherited information changes outside of /// function at other times if their inherited information changes outside of
/// the build phase. /// the build phase. [InheritedWidget] subclasses can also call this directly
@protected /// by first obtaining their [InheritedElement] using
/// [BuildContext.ancestorInheritedElementForWidgetOfExactType].
void dispatchDependenciesChanged() { void dispatchDependenciesChanged() {
for (Element dependent in _dependents) { for (Element dependent in _dependents) {
assert(() { assert(() {
......
...@@ -14,17 +14,17 @@ void main() { ...@@ -14,17 +14,17 @@ void main() {
mockTextInput.enterText(text); mockTextInput.enterText(text);
} }
testWidgets('Setter callback is called', (WidgetTester tester) async { testWidgets('onSaved callback is called', (WidgetTester tester) async {
GlobalKey<FormState> formKey = new GlobalKey<FormState>();
String fieldValue; String fieldValue;
Widget builder() { Widget builder() {
return new Center( return new Center(
child: new Material( child: new Material(
child: new Form( child: new Form(
child: new Input( key: formKey,
formField: new FormField<String>( child: new InputFormField(
setter: (String value) { fieldValue = value; } onSaved: (InputValue value) { fieldValue = value.text; }
)
) )
) )
) )
...@@ -38,6 +38,7 @@ void main() { ...@@ -38,6 +38,7 @@ void main() {
Future<Null> checkText(String testValue) async { Future<Null> checkText(String testValue) async {
enterText(testValue); enterText(testValue);
await tester.idle(); await tester.idle();
formKey.currentState.save();
// pump'ing is unnecessary because callback happens regardless of frames // pump'ing is unnecessary because callback happens regardless of frames
expect(fieldValue, equals(testValue)); expect(fieldValue, equals(testValue));
} }
...@@ -48,17 +49,15 @@ void main() { ...@@ -48,17 +49,15 @@ void main() {
testWidgets('Validator sets the error text', (WidgetTester tester) async { testWidgets('Validator sets the error text', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey(); GlobalKey inputKey = new GlobalKey();
String errorText(String input) => input + '/error'; String errorText(InputValue input) => input.text + '/error';
Widget builder() { Widget builder() {
return new Center( return new Center(
child: new Material( child: new Material(
child: new Form( child: new Form(
child: new Input( child: new InputFormField(
key: inputKey, key: inputKey,
formField: new FormField<String>( validator: errorText
validator: errorText
)
) )
) )
) )
...@@ -72,7 +71,7 @@ void main() { ...@@ -72,7 +71,7 @@ void main() {
await tester.idle(); await tester.idle();
await tester.pump(); await tester.pump();
// Check for a new Text widget with our error text. // Check for a new Text widget with our error text.
expect(find.text(errorText(testValue)), findsOneWidget); expect(find.text(errorText(new InputValue(text: testValue))), findsOneWidget);
} }
await checkErrorText('Test'); await checkErrorText('Test');
...@@ -80,31 +79,29 @@ void main() { ...@@ -80,31 +79,29 @@ void main() {
}); });
testWidgets('Multiple Inputs communicate', (WidgetTester tester) async { testWidgets('Multiple Inputs communicate', (WidgetTester tester) async {
GlobalKey inputKey = new GlobalKey(); GlobalKey<FormState> formKey = new GlobalKey<FormState>();
GlobalKey<FormFieldState<InputValue>> fieldKey = new GlobalKey<FormFieldState<InputValue>>();
GlobalKey inputFocusKey = new GlobalKey();
GlobalKey focusKey = new GlobalKey(); GlobalKey focusKey = new GlobalKey();
// Input 1's text value.
String fieldValue;
// Input 2's validator depends on a input 1's value. // 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() { Widget builder() {
return new Center( return new Center(
child: new Material( child: new Material(
child: new Form( child: new Form(
key: formKey,
child: new Focus( child: new Focus(
key: focusKey, key: focusKey,
child: new Block( child: new Block(
children: <Widget>[ children: <Widget>[
new Input( new InputFormField(
key: inputKey, autofocus: true,
formField: new FormField<String>( key: fieldKey,
setter: (String value) { fieldValue = value; } focusKey: inputFocusKey,
)
), ),
new Input( new InputFormField(
formField: new FormField<String>( validator: errorText
validator: errorText
)
) )
] ]
) )
...@@ -115,18 +112,16 @@ void main() { ...@@ -115,18 +112,16 @@ void main() {
} }
await tester.pumpWidget(builder()); await tester.pumpWidget(builder());
Focus.moveTo(inputKey);
await tester.pump(); await tester.pump();
Focus.moveTo(inputFocusKey);
Future<Null> checkErrorText(String testValue) async { Future<Null> checkErrorText(String testValue) async {
enterText(testValue); enterText(testValue);
await tester.idle(); await tester.idle();
await tester.pump(); await tester.pump();
expect(fieldValue, equals(testValue));
// Check for a new Text widget with our error text. // Check for a new Text widget with our error text.
expect(find.text(errorText(testValue)), findsOneWidget); expect(find.text(testValue + '/error'), findsOneWidget);
return null; return null;
} }
...@@ -136,20 +131,18 @@ void main() { ...@@ -136,20 +131,18 @@ void main() {
testWidgets('Provide initial value to input', (WidgetTester tester) async { testWidgets('Provide initial value to input', (WidgetTester tester) async {
String initialValue = 'hello'; String initialValue = 'hello';
String currentValue; GlobalKey<FormFieldState<InputValue>> inputKey = new GlobalKey<FormFieldState<InputValue>>();
Widget builder() { Widget builder() {
return new Center( return new Center(
child: new Material( child: new Material(
child: new Form( child: new Form(
child: new Input( child: new InputFormField(
value: new InputValue(text: initialValue), key: inputKey,
formField: new FormField<String>( initialValue: new InputValue(text: initialValue),
setter: (String value) { currentValue = value; } )
)
)
)
) )
)
); );
} }
...@@ -164,12 +157,63 @@ void main() { ...@@ -164,12 +157,63 @@ void main() {
expect(editableText.config.value.text, equals(initialValue)); expect(editableText.config.value.text, equals(initialValue));
// sanity check, make sure we can still edit the text and everything updates // sanity check, make sure we can still edit the text and everything updates
expect(currentValue, isNull); expect(inputKey.currentState.value.text, equals(initialValue));
enterText('world'); enterText('world');
await tester.idle(); await tester.idle();
expect(currentValue, equals('world'));
await tester.pump(); await tester.pump();
expect(inputKey.currentState.value.text, equals('world'));
expect(editableText.config.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<FormState> formKey = new GlobalKey<FormState>();
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);
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment