From a7b28a3ede4639d51a59a063f28365e968ffda3a Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Fri, 25 Mar 2016 17:55:38 -0400 Subject: [PATCH] Added a Form widget to manage multiple Input widgets. --- .../lib/demo/text_field_demo.dart | 133 +++++++------- packages/flutter/lib/src/material/input.dart | 67 ++++++- packages/flutter/lib/src/widgets/form.dart | 100 +++++++++++ packages/flutter/lib/widgets.dart | 1 + packages/flutter/test/widget/form_test.dart | 168 ++++++++++++++++++ 5 files changed, 393 insertions(+), 76 deletions(-) create mode 100644 packages/flutter/lib/src/widgets/form.dart create mode 100644 packages/flutter/test/widget/form_test.dart diff --git a/examples/material_gallery/lib/demo/text_field_demo.dart b/examples/material_gallery/lib/demo/text_field_demo.dart index a5bf2d992d..8baea20e64 100644 --- a/examples/material_gallery/lib/demo/text_field_demo.dart +++ b/examples/material_gallery/lib/demo/text_field_demo.dart @@ -11,14 +11,16 @@ class TextFieldDemo extends StatefulWidget { TextFieldDemoState createState() => new TextFieldDemoState(); } +class PersonData { + String name; + String phoneNumber; + String password; +} + class TextFieldDemoState extends State { final GlobalKey _scaffoldKey = new GlobalKey(); - final List _inputs = [ - 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 { )); } - 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,52 +63,55 @@ class TextFieldDemoState extends State { appBar: new AppBar( title: new Text('Text Fields') ), - body: new Block( - padding: const EdgeInsets.all(8.0), - children: [ - 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 - ), - 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 - ), - new Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - new Flexible( - child: new Input( - hintText: 'How do you log in?', - labelText: 'New Password', - hideText: true, - value: _inputs[2], - onChanged: (InputValue value) { _handleInputChanged(value, 2); }, - onSubmitted: _handleInputSubmitted - ) - ), - 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 - ) + body: new Form( + onSubmitted: _handleSubmitted, + child: new Block( + padding: const EdgeInsets.all(8.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 ) - ] - ) - ] + ), + new Input( + hintText: 'Where can we reach you?', + labelText: 'Phone Number', + formField: new FormField( + setter: (String val) { person.phoneNumber = val; }, + validator: _validatePhoneNumber + ) + ), + new Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + new Flexible( + child: new Input( + hintText: 'How do you log in?', + labelText: 'New Password', + hideText: true, + formField: new FormField( + setter: (String val) { person.password = val; } + ) + ) + ), + new Flexible( + child: new Input( + hintText: 'How do you log in?', + labelText: 'Re-type Password', + hideText: true, + formField: new FormField( + validator: _validatePassword + ) + ) + ) + ] + ) + ] + ) ) ); } diff --git a/packages/flutter/lib/src/material/input.dart b/packages/flutter/lib/src/material/input.dart index 40bb04de48..161e1d9245 100644 --- a/packages/flutter/lib/src/material/input.dart +++ b/packages/flutter/lib/src/material/input.dart @@ -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 formField; + /// Called when the text being edited changes. final ValueChanged onChanged; @@ -79,12 +83,24 @@ class _InputState extends State { 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 onChanged = config.onChanged ?? _formData?.onChanged; + ValueChanged 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 { List stackChildren = []; - 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 { 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 { 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 { ), 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 { ); } } + +class _FormFieldData { + _FormFieldData(this.inputState) { + assert(field != null); + } + + InputValue value = new InputValue(); + final _InputState inputState; + 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; + } + + 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(); + } +} diff --git a/packages/flutter/lib/src/widgets/form.dart b/packages/flutter/lib/src/widgets/form.dart new file mode 100644 index 0000000000..5008aad06a --- /dev/null +++ b/packages/flutter/lib/src/widgets/form.dart @@ -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
{ + 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 value); +typedef void FormFieldSetter(T newValue); + +/// This contains identifying information for Input fields, required if the +/// Input is part of a Form. +class FormField { + 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; +} + +/// 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; +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 451b3386f9..f2db1ed846 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widget/form_test.dart b/packages/flutter/test/widget/form_test.dart new file mode 100644 index 0000000000..b335cd67e5 --- /dev/null +++ b/packages/flutter/test/widget/form_test.dart @@ -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( + 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( + 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: [ + new Input( + key: inputKey, + formField: new FormField( + setter: (String val) { fieldValue = val; } + ) + ), + new Input( + formField: new FormField( + 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(''); + }); + }); +}