Added a Form widget to manage multiple Input widgets.
This commit is contained in:
parent
03830d5676
commit
a7b28a3ede
@ -11,14 +11,16 @@ class TextFieldDemo extends StatefulWidget {
|
||||
TextFieldDemoState createState() => new TextFieldDemoState();
|
||||
}
|
||||
|
||||
class PersonData {
|
||||
String name;
|
||||
String phoneNumber;
|
||||
String password;
|
||||
}
|
||||
|
||||
class TextFieldDemoState extends State<TextFieldDemo> {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
|
||||
final List<InputValue> _inputs = <InputValue>[
|
||||
InputValue.empty,
|
||||
InputValue.empty,
|
||||
InputValue.empty,
|
||||
InputValue.empty,
|
||||
];
|
||||
|
||||
PersonData person = new PersonData();
|
||||
|
||||
void showInSnackBar(String value) {
|
||||
_scaffoldKey.currentState.showSnackBar(new SnackBar(
|
||||
@ -26,36 +28,30 @@ class TextFieldDemoState extends State<TextFieldDemo> {
|
||||
));
|
||||
}
|
||||
|
||||
void _handleInputChanged(InputValue value, int which) {
|
||||
setState(() {
|
||||
_inputs[which] = value;
|
||||
});
|
||||
void _handleSubmitted() {
|
||||
showInSnackBar('${person.name}\'s phone number is ${person.phoneNumber}');
|
||||
}
|
||||
|
||||
void _handleInputSubmitted(InputValue value) {
|
||||
showInSnackBar('${_inputs[0].text}\'s phone number is ${_inputs[1].text}');
|
||||
}
|
||||
|
||||
String _validateName(InputValue value) {
|
||||
if (value.text.isEmpty)
|
||||
String _validateName(String value) {
|
||||
if (value.isEmpty)
|
||||
return 'Name is required.';
|
||||
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) {
|
||||
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 value1, InputValue value2) {
|
||||
if (value1.text.isEmpty)
|
||||
String _validatePassword(String value) {
|
||||
if (person.password == null || person.password.isEmpty)
|
||||
return 'Please choose a password.';
|
||||
if (value1.text != value2.text)
|
||||
if (person.password != value)
|
||||
return 'Passwords don\'t match';
|
||||
return null;
|
||||
}
|
||||
@ -67,24 +63,27 @@ class TextFieldDemoState extends State<TextFieldDemo> {
|
||||
appBar: new AppBar(
|
||||
title: new Text('Text Fields')
|
||||
),
|
||||
body: new Block(
|
||||
body: new Form(
|
||||
onSubmitted: _handleSubmitted,
|
||||
child: new Block(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
children: <Widget>[
|
||||
new Input(
|
||||
hintText: 'What do people call you?',
|
||||
labelText: 'Name',
|
||||
errorText: _validateName(_inputs[0]),
|
||||
value: _inputs[0],
|
||||
onChanged: (InputValue value) { _handleInputChanged(value, 0); },
|
||||
onSubmitted: _handleInputSubmitted
|
||||
formField: new FormField<String>(
|
||||
// TODO(mpcomplete): replace with person#name=
|
||||
setter: (String val) { person.name = val; },
|
||||
validator: _validateName
|
||||
)
|
||||
),
|
||||
new Input(
|
||||
hintText: 'Where can we reach you?',
|
||||
labelText: 'Phone Number',
|
||||
errorText: _validatePhoneNumber(_inputs[1]),
|
||||
value: _inputs[1],
|
||||
onChanged: (InputValue value) { _handleInputChanged(value, 1); },
|
||||
onSubmitted: _handleInputSubmitted
|
||||
formField: new FormField<String>(
|
||||
setter: (String val) { person.phoneNumber = val; },
|
||||
validator: _validatePhoneNumber
|
||||
)
|
||||
),
|
||||
new Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -94,26 +93,26 @@ class TextFieldDemoState extends State<TextFieldDemo> {
|
||||
hintText: 'How do you log in?',
|
||||
labelText: 'New Password',
|
||||
hideText: true,
|
||||
value: _inputs[2],
|
||||
onChanged: (InputValue value) { _handleInputChanged(value, 2); },
|
||||
onSubmitted: _handleInputSubmitted
|
||||
formField: new FormField<String>(
|
||||
setter: (String val) { person.password = val; }
|
||||
)
|
||||
)
|
||||
),
|
||||
new Flexible(
|
||||
child: new Input(
|
||||
hintText: 'How do you log in?',
|
||||
labelText: 'Re-type Password',
|
||||
errorText: _validatePassword(_inputs[2], _inputs[3]),
|
||||
hideText: true,
|
||||
value: _inputs[3],
|
||||
onChanged: (InputValue value) { _handleInputChanged(value, 3); },
|
||||
onSubmitted: _handleInputSubmitted
|
||||
formField: new FormField<String>(
|
||||
validator: _validatePassword
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export 'package:sky_services/editing/editing.mojom.dart' show KeyboardType;
|
||||
class Input extends StatefulWidget {
|
||||
Input({
|
||||
Key key,
|
||||
this.value: InputValue.empty,
|
||||
this.value,
|
||||
this.keyboardType: KeyboardType.text,
|
||||
this.icon,
|
||||
this.labelText,
|
||||
@ -27,6 +27,7 @@ class Input extends StatefulWidget {
|
||||
this.hideText: false,
|
||||
this.isDense: false,
|
||||
this.autofocus: false,
|
||||
this.formField,
|
||||
this.onChanged,
|
||||
this.onSubmitted
|
||||
}) : super(key: key);
|
||||
@ -61,6 +62,9 @@ class Input extends StatefulWidget {
|
||||
/// Whether this input field should focus itself is nothing else is already focused.
|
||||
final bool autofocus;
|
||||
|
||||
/// Form-specific data, required if this Input is part of a Form.
|
||||
final FormField<String> formField;
|
||||
|
||||
/// Called when the text being edited changes.
|
||||
final ValueChanged<InputValue> onChanged;
|
||||
|
||||
@ -79,12 +83,24 @@ class _InputState extends State<Input> {
|
||||
|
||||
GlobalKey get focusKey => config.key is GlobalKey ? config.key : _rawInputLineKey;
|
||||
|
||||
// Optional state to retain if we are inside a Form widget.
|
||||
_FormFieldData _formData;
|
||||
|
||||
@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);
|
||||
InputValue value = config.value ?? _formData?.value ?? InputValue.empty;
|
||||
ValueChanged<InputValue> onChanged = config.onChanged ?? _formData?.onChanged;
|
||||
ValueChanged<InputValue> onSubmitted = config.onSubmitted ?? _formData?.onSubmitted;
|
||||
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;
|
||||
@ -102,7 +118,7 @@ class _InputState extends State<Input> {
|
||||
|
||||
List<Widget> stackChildren = <Widget>[];
|
||||
|
||||
bool hasInlineLabel = config.labelText != null && !focused && !config.value.text.isNotEmpty;
|
||||
bool hasInlineLabel = config.labelText != null && !focused && !value.text.isNotEmpty;
|
||||
|
||||
if (config.labelText != null) {
|
||||
TextStyle labelStyle = hasInlineLabel ?
|
||||
@ -125,7 +141,7 @@ class _InputState extends State<Input> {
|
||||
topPadding += topPaddingIncrement;
|
||||
}
|
||||
|
||||
if (config.hintText != null && config.value.text.isEmpty && !hasInlineLabel) {
|
||||
if (config.hintText != null && value.text.isEmpty && !hasInlineLabel) {
|
||||
TextStyle hintStyle = themeData.textTheme.subhead.copyWith(color: themeData.hintColor);
|
||||
stackChildren.add(new Positioned(
|
||||
left: 0.0,
|
||||
@ -139,7 +155,7 @@ class _InputState extends State<Input> {
|
||||
Color borderColor = activeColor;
|
||||
double borderWidth = focused ? 2.0 : 1.0;
|
||||
|
||||
if (config.errorText != null) {
|
||||
if (errorText != null) {
|
||||
borderColor = themeData.errorColor;
|
||||
borderWidth = 2.0;
|
||||
if (!config.isDense) {
|
||||
@ -163,24 +179,24 @@ class _InputState extends State<Input> {
|
||||
),
|
||||
child: new RawInputLine(
|
||||
key: _rawInputLineKey,
|
||||
value: config.value,
|
||||
value: value,
|
||||
focusKey: focusKey,
|
||||
style: textStyle,
|
||||
hideText: config.hideText,
|
||||
cursorColor: themeData.selectionColor,
|
||||
selectionColor: themeData.selectionColor,
|
||||
keyboardType: config.keyboardType,
|
||||
onChanged: config.onChanged,
|
||||
onSubmitted: config.onSubmitted
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted
|
||||
)
|
||||
));
|
||||
|
||||
if (config.errorText != null && !config.isDense) {
|
||||
if (errorText != null && !config.isDense) {
|
||||
TextStyle errorStyle = themeData.textTheme.caption.copyWith(color: themeData.errorColor);
|
||||
stackChildren.add(new Positioned(
|
||||
left: 0.0,
|
||||
bottom: 0.0,
|
||||
child: new Text(config.errorText, style: errorStyle)
|
||||
child: new Text(errorText, style: errorStyle)
|
||||
));
|
||||
}
|
||||
|
||||
@ -216,3 +232,36 @@ class _InputState extends State<Input> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormFieldData {
|
||||
_FormFieldData(this.inputState) {
|
||||
assert(field != null);
|
||||
}
|
||||
|
||||
InputValue value = new InputValue();
|
||||
final _InputState inputState;
|
||||
FormField<String> 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;
|
||||
}
|
||||
|
||||
void onChanged(InputValue value) {
|
||||
FormScope scope = FormScope.of(inputState.context);
|
||||
assert(scope != null);
|
||||
this.value = value;
|
||||
if (field.setter != null)
|
||||
field.setter(value.text);
|
||||
scope.onFieldChanged();
|
||||
}
|
||||
|
||||
void onSubmitted(InputValue value) {
|
||||
FormScope scope = FormScope.of(inputState.context);
|
||||
assert(scope != null);
|
||||
scope.form.onSubmitted();
|
||||
scope.onFieldChanged();
|
||||
}
|
||||
}
|
||||
|
100
packages/flutter/lib/src/widgets/form.dart
Normal file
100
packages/flutter/lib/src/widgets/form.dart
Normal file
@ -0,0 +1,100 @@
|
||||
// Copyright 2016 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 'basic.dart';
|
||||
import 'framework.dart';
|
||||
|
||||
/// A container for grouping together multiple form field widgets (e.g. Input).
|
||||
class Form extends StatefulWidget {
|
||||
Form({
|
||||
Key key,
|
||||
this.child,
|
||||
this.onSubmitted
|
||||
}) : super(key: key) {
|
||||
assert(child != null);
|
||||
}
|
||||
|
||||
/// Called when the input is accepted anywhere on the form.
|
||||
final VoidCallback onSubmitted;
|
||||
|
||||
/// Root of the widget hierarchy that contains this form.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
_FormState createState() => new _FormState();
|
||||
}
|
||||
|
||||
class _FormState extends State<Form> {
|
||||
int generation = 0;
|
||||
|
||||
void onFieldChanged() {
|
||||
setState(() {
|
||||
++generation;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new FormScope(
|
||||
state: this,
|
||||
generation: generation,
|
||||
child: config.child
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef String FormFieldValidator<T>(T value);
|
||||
typedef void FormFieldSetter<T>(T newValue);
|
||||
|
||||
/// This contains identifying information for Input fields, required if the
|
||||
/// Input is part of a Form.
|
||||
class FormField<T> {
|
||||
FormField({
|
||||
this.setter,
|
||||
this.validator
|
||||
});
|
||||
|
||||
/// An optional method to call with the new value when the form field changes.
|
||||
final FormFieldSetter<T> setter;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// The root of all Forms. Used by form field widgets (e.g. Input) to
|
||||
/// communicate changes back to the client.
|
||||
class FormScope extends InheritedWidget {
|
||||
FormScope({
|
||||
Key key,
|
||||
Widget child,
|
||||
_FormState state,
|
||||
int generation
|
||||
}) : _state = state,
|
||||
_generation = generation,
|
||||
super(key: key, child: child);
|
||||
|
||||
final _FormState _state;
|
||||
|
||||
/// Incremented every time a form field has changed. This lets us know when
|
||||
/// to rebuild the form.
|
||||
final int _generation;
|
||||
|
||||
/// The Form this widget belongs to.
|
||||
Form get form => _state.config;
|
||||
|
||||
/// Finds the FormScope that encloses the widget being built from the given
|
||||
/// context.
|
||||
static FormScope of(BuildContext context) {
|
||||
return context.inheritFromWidgetOfExactType(FormScope);
|
||||
}
|
||||
|
||||
/// 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() => _state.onFieldChanged();
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(FormScope old) => _generation != old._generation;
|
||||
}
|
@ -17,6 +17,7 @@ export 'src/widgets/dismissable.dart';
|
||||
export 'src/widgets/drag_target.dart';
|
||||
export 'src/widgets/editable.dart';
|
||||
export 'src/widgets/focus.dart';
|
||||
export 'src/widgets/form.dart';
|
||||
export 'src/widgets/framework.dart';
|
||||
export 'src/widgets/gesture_detector.dart';
|
||||
export 'src/widgets/gridpaper.dart';
|
||||
|
168
packages/flutter/test/widget/form_test.dart
Normal file
168
packages/flutter/test/widget/form_test.dart
Normal file
@ -0,0 +1,168 @@
|
||||
// Copyright 2016 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_test/flutter_test.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sky_services/editing/editing.mojom.dart' as mojom;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class MockKeyboard implements mojom.Keyboard {
|
||||
mojom.KeyboardClient client;
|
||||
|
||||
@override
|
||||
void setClient(mojom.KeyboardClientStub client, mojom.KeyboardConfiguration configuraiton) {
|
||||
this.client = client.impl;
|
||||
}
|
||||
|
||||
@override
|
||||
void show() {}
|
||||
|
||||
@override
|
||||
void hide() {}
|
||||
|
||||
@override
|
||||
void setEditingState(mojom.EditingState state) {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
WidgetFlutterBinding.ensureInitialized(); // for serviceMocker
|
||||
MockKeyboard mockKeyboard = new MockKeyboard();
|
||||
serviceMocker.registerMockService(mojom.Keyboard.serviceName, mockKeyboard);
|
||||
|
||||
void enterText(String testValue) {
|
||||
// Simulate entry of text through the keyboard.
|
||||
expect(mockKeyboard.client, isNotNull);
|
||||
mockKeyboard.client.updateEditingState(new mojom.EditingState()
|
||||
..text = testValue
|
||||
..composingBase = 0
|
||||
..composingExtent = testValue.length);
|
||||
}
|
||||
|
||||
test('Setter callback is called', () {
|
||||
testWidgets((WidgetTester tester) {
|
||||
GlobalKey inputKey = new GlobalKey();
|
||||
String fieldValue;
|
||||
|
||||
Widget builder() {
|
||||
return new Center(
|
||||
child: new Material(
|
||||
child: new Form(
|
||||
child: new Input(
|
||||
key: inputKey,
|
||||
formField: new FormField<String>(
|
||||
setter: (String val) { fieldValue = val; }
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
tester.pumpWidget(builder());
|
||||
|
||||
void checkText(String testValue) {
|
||||
enterText(testValue);
|
||||
|
||||
// Check that the FormField's setter was called.
|
||||
expect(fieldValue, equals(testValue));
|
||||
tester.pumpWidget(builder());
|
||||
}
|
||||
|
||||
checkText('Test');
|
||||
checkText('');
|
||||
});
|
||||
});
|
||||
|
||||
test('Validator sets the error text', () {
|
||||
testWidgets((WidgetTester tester) {
|
||||
GlobalKey inputKey = new GlobalKey();
|
||||
String errorText(String input) => input + '/error';
|
||||
|
||||
Widget builder() {
|
||||
return new Center(
|
||||
child: new Material(
|
||||
child: new Form(
|
||||
child: new Input(
|
||||
key: inputKey,
|
||||
formField: new FormField<String>(
|
||||
validator: errorText
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
tester.pumpWidget(builder());
|
||||
|
||||
void checkErrorText(String testValue) {
|
||||
enterText(testValue);
|
||||
tester.pumpWidget(builder());
|
||||
|
||||
// Check for a new Text widget with our error text.
|
||||
Element errorElement = tester.findText(errorText(testValue));
|
||||
expect(errorElement, isNotNull);
|
||||
}
|
||||
|
||||
checkErrorText('Test');
|
||||
checkErrorText('');
|
||||
});
|
||||
});
|
||||
|
||||
test('Multiple Inputs communicate', () {
|
||||
testWidgets((WidgetTester tester) {
|
||||
GlobalKey inputKey = 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';
|
||||
|
||||
Widget builder() {
|
||||
return new Center(
|
||||
child: new Material(
|
||||
child: new Form(
|
||||
child: new Focus(
|
||||
key: focusKey,
|
||||
child: new Block(
|
||||
children: <Widget>[
|
||||
new Input(
|
||||
key: inputKey,
|
||||
formField: new FormField<String>(
|
||||
setter: (String val) { fieldValue = val; }
|
||||
)
|
||||
),
|
||||
new Input(
|
||||
formField: new FormField<String>(
|
||||
validator: errorText
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
tester.pumpWidget(builder());
|
||||
Focus.moveTo(inputKey);
|
||||
tester.pump();
|
||||
|
||||
void checkErrorText(String testValue) {
|
||||
enterText(testValue);
|
||||
tester.pumpWidget(builder());
|
||||
|
||||
expect(fieldValue, equals(testValue));
|
||||
|
||||
// Check for a new Text widget with our error text.
|
||||
Element errorElement = tester.findText(errorText(testValue));
|
||||
expect(errorElement, isNotNull);
|
||||
}
|
||||
|
||||
checkErrorText('Test');
|
||||
checkErrorText('');
|
||||
});
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user