Unverified Commit 178130b1 authored by Renzo Olivares's avatar Renzo Olivares Committed by GitHub

TextField and TextFormField can use a MaterialStatesController (#133977)

This change adds support for a `MaterialStatesController` in `TextField` and `TextFormField`. With this change a user can listen to `MaterialState` changes in an input field by passing a `MaterialStatesController` to `TextField` or `TextFormField`.

Fixes #133273
parent 49632fc3
...@@ -273,6 +273,7 @@ class TextField extends StatefulWidget { ...@@ -273,6 +273,7 @@ class TextField extends StatefulWidget {
this.toolbarOptions, this.toolbarOptions,
this.showCursor, this.showCursor,
this.autofocus = false, this.autofocus = false,
this.statesController,
this.obscuringCharacter = '•', this.obscuringCharacter = '•',
this.obscureText = false, this.obscureText = false,
this.autocorrect = true, this.autocorrect = true,
...@@ -457,6 +458,27 @@ class TextField extends StatefulWidget { ...@@ -457,6 +458,27 @@ class TextField extends StatefulWidget {
/// {@macro flutter.widgets.editableText.autofocus} /// {@macro flutter.widgets.editableText.autofocus}
final bool autofocus; final bool autofocus;
/// Represents the interactive "state" of this widget in terms of a set of
/// [MaterialState]s, including [MaterialState.disabled], [MaterialState.hovered],
/// [MaterialState.error], and [MaterialState.focused].
///
/// Classes based on this one can provide their own
/// [MaterialStatesController] to which they've added listeners.
/// They can also update the controller's [MaterialStatesController.value]
/// however, this may only be done when it's safe to call
/// [State.setState], like in an event handler.
///
/// The controller's [MaterialStatesController.value] represents the set of
/// states that a widget's visual properties, typically [MaterialStateProperty]
/// values, are resolved against. It is _not_ the intrinsic state of the widget.
/// The widget is responsible for ensuring that the controller's
/// [MaterialStatesController.value] tracks its intrinsic state. For example
/// one cannot request the keyboard focus for a widget by adding [MaterialState.focused]
/// to its controller. When the widget gains the or loses the focus it will
/// [MaterialStatesController.update] its controller's [MaterialStatesController.value]
/// and notify listeners of the change.
final MaterialStatesController? statesController;
/// {@macro flutter.widgets.editableText.obscuringCharacter} /// {@macro flutter.widgets.editableText.obscuringCharacter}
final String obscuringCharacter; final String obscuringCharacter;
...@@ -970,7 +992,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -970,7 +992,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
int get _currentLength => _effectiveController.value.text.characters.length; int get _currentLength => _effectiveController.value.text.characters.length;
bool get _hasIntrinsicError => widget.maxLength != null && widget.maxLength! > 0 && _effectiveController.value.text.characters.length > widget.maxLength!; bool get _hasIntrinsicError => widget.maxLength != null &&
widget.maxLength! > 0 &&
(widget.controller == null ?
!restorePending && _effectiveController.value.text.characters.length > widget.maxLength! :
_effectiveController.value.text.characters.length > widget.maxLength!);
bool get _hasError => widget.decoration?.errorText != null || widget.decoration?.error != null || _hasIntrinsicError; bool get _hasError => widget.decoration?.errorText != null || widget.decoration?.error != null || _hasIntrinsicError;
...@@ -1055,6 +1081,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1055,6 +1081,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
} }
_effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled;
_effectiveFocusNode.addListener(_handleFocusChanged); _effectiveFocusNode.addListener(_handleFocusChanged);
_initStatesController();
} }
bool get _canRequestFocus { bool get _canRequestFocus {
...@@ -1096,6 +1123,20 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1096,6 +1123,20 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
_showSelectionHandles = !widget.readOnly; _showSelectionHandles = !widget.readOnly;
} }
} }
if (widget.statesController == oldWidget.statesController) {
_statesController.update(MaterialState.disabled, !_isEnabled);
_statesController.update(MaterialState.hovered, _isHovering);
_statesController.update(MaterialState.focused, _effectiveFocusNode.hasFocus);
_statesController.update(MaterialState.error, _hasError);
} else {
oldWidget.statesController?.removeListener(_handleStatesControllerChange);
if (widget.statesController != null) {
_internalStatesController?.dispose();
_internalStatesController = null;
}
_initStatesController();
}
} }
@override @override
...@@ -1128,6 +1169,8 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1128,6 +1169,8 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
_effectiveFocusNode.removeListener(_handleFocusChanged); _effectiveFocusNode.removeListener(_handleFocusChanged);
_focusNode?.dispose(); _focusNode?.dispose();
_controller?.dispose(); _controller?.dispose();
_statesController.removeListener(_handleStatesControllerChange);
_internalStatesController?.dispose();
super.dispose(); super.dispose();
} }
...@@ -1172,6 +1215,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1172,6 +1215,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
// Rebuild the widget on focus change to show/hide the text selection // Rebuild the widget on focus change to show/hide the text selection
// highlight. // highlight.
}); });
_statesController.update(MaterialState.focused, _effectiveFocusNode.hasFocus);
} }
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
...@@ -1220,7 +1264,29 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1220,7 +1264,29 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
setState(() { setState(() {
_isHovering = hovering; _isHovering = hovering;
}); });
_statesController.update(MaterialState.hovered, _isHovering);
}
}
// Material states controller.
MaterialStatesController? _internalStatesController;
void _handleStatesControllerChange() {
// Force a rebuild to resolve MaterialStateProperty properties.
setState(() { });
}
MaterialStatesController get _statesController => widget.statesController ?? _internalStatesController!;
void _initStatesController() {
if (widget.statesController == null) {
_internalStatesController = MaterialStatesController();
} }
_statesController.update(MaterialState.disabled, !_isEnabled);
_statesController.update(MaterialState.hovered, _isHovering);
_statesController.update(MaterialState.focused, _effectiveFocusNode.hasFocus);
_statesController.update(MaterialState.error, _hasError);
_statesController.addListener(_handleStatesControllerChange);
} }
// AutofillClient implementation start. // AutofillClient implementation start.
...@@ -1246,19 +1312,10 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1246,19 +1312,10 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
} }
// AutofillClient implementation end. // AutofillClient implementation end.
Set<MaterialState> get _materialState {
return <MaterialState>{
if (!_isEnabled) MaterialState.disabled,
if (_isHovering) MaterialState.hovered,
if (_effectiveFocusNode.hasFocus) MaterialState.focused,
if (_hasError) MaterialState.error,
};
}
TextStyle _getInputStyleForState(TextStyle style) { TextStyle _getInputStyleForState(TextStyle style) {
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
final TextStyle stateStyle = MaterialStateProperty.resolveAs(theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, _materialState); final TextStyle stateStyle = MaterialStateProperty.resolveAs(theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, _statesController.value);
final TextStyle providedStyle = MaterialStateProperty.resolveAs(style, _materialState); final TextStyle providedStyle = MaterialStateProperty.resolveAs(style, _statesController.value);
return providedStyle.merge(stateStyle); return providedStyle.merge(stateStyle);
} }
...@@ -1275,7 +1332,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1275,7 +1332,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context); final DefaultSelectionStyle selectionStyle = DefaultSelectionStyle.of(context);
final TextStyle? providedStyle = MaterialStateProperty.resolveAs(widget.style, _materialState); final TextStyle? providedStyle = MaterialStateProperty.resolveAs(widget.style, _statesController.value);
final TextStyle style = _getInputStyleForState(theme.useMaterial3 ? _m3InputStyle(context) : theme.textTheme.titleMedium!).merge(providedStyle); final TextStyle style = _getInputStyleForState(theme.useMaterial3 ? _m3InputStyle(context) : theme.textTheme.titleMedium!).merge(providedStyle);
final Brightness keyboardAppearance = widget.keyboardAppearance ?? theme.brightness; final Brightness keyboardAppearance = widget.keyboardAppearance ?? theme.brightness;
final TextEditingController controller = _effectiveController; final TextEditingController controller = _effectiveController;
...@@ -1490,7 +1547,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1490,7 +1547,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
} }
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>( final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
widget.mouseCursor ?? MaterialStateMouseCursor.textable, widget.mouseCursor ?? MaterialStateMouseCursor.textable,
_materialState, _statesController.value,
); );
final int? semanticsMaxValueLength; final int? semanticsMaxValueLength;
......
...@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart'; ...@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart'; import 'adaptive_text_selection_toolbar.dart';
import 'input_decorator.dart'; import 'input_decorator.dart';
import 'material_state.dart';
import 'text_field.dart'; import 'text_field.dart';
import 'theme.dart'; import 'theme.dart';
...@@ -167,6 +168,7 @@ class TextFormField extends FormField<String> { ...@@ -167,6 +168,7 @@ class TextFormField extends FormField<String> {
ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight, ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ContentInsertionConfiguration? contentInsertionConfiguration, ContentInsertionConfiguration? contentInsertionConfiguration,
MaterialStatesController? statesController,
Clip clipBehavior = Clip.hardEdge, Clip clipBehavior = Clip.hardEdge,
bool scribbleEnabled = true, bool scribbleEnabled = true,
bool canRequestFocus = true, bool canRequestFocus = true,
...@@ -212,6 +214,7 @@ class TextFormField extends FormField<String> { ...@@ -212,6 +214,7 @@ class TextFormField extends FormField<String> {
textDirection: textDirection, textDirection: textDirection,
textCapitalization: textCapitalization, textCapitalization: textCapitalization,
autofocus: autofocus, autofocus: autofocus,
statesController: statesController,
toolbarOptions: toolbarOptions, toolbarOptions: toolbarOptions,
readOnly: readOnly, readOnly: readOnly,
showCursor: showCursor, showCursor: showCursor,
......
...@@ -6822,6 +6822,154 @@ void main() { ...@@ -6822,6 +6822,154 @@ void main() {
expect(editableText.style.color, theme.textTheme.bodyLarge!.color!.withOpacity(0.38)); expect(editableText.style.color, theme.textTheme.bodyLarge!.color!.withOpacity(0.38));
}); });
testWidgets('Enabled TextField statesController', (WidgetTester tester) async {
final TextEditingController textEditingController = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
int count = 0;
void valueChanged() {
count += 1;
}
final MaterialStatesController statesController = MaterialStatesController();
statesController.addListener(valueChanged);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
statesController: statesController,
controller: textEditingController,
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
final Offset center = tester.getCenter(find.byType(EditableText).first);
await gesture.moveTo(center);
await tester.pump();
expect(statesController.value, <MaterialState>{MaterialState.hovered});
expect(count, 1);
await gesture.moveTo(Offset.zero);
await tester.pump();
expect(statesController.value, <MaterialState>{});
expect(count, 2);
await gesture.down(center);
await tester.pump();
await gesture.up();
await tester.pump();
expect(statesController.value, <MaterialState>{MaterialState.hovered, MaterialState.focused});
expect(count, 4); // adds hovered and pressed - two changes.
await gesture.moveTo(Offset.zero);
await tester.pump();
expect(statesController.value, <MaterialState>{MaterialState.focused});
expect(count, 5);
await gesture.down(Offset.zero);
await tester.pump();
expect(statesController.value, <MaterialState>{});
expect(count, 6);
await gesture.up();
await tester.pump();
await gesture.down(center);
await tester.pump();
await gesture.up();
await tester.pump();
expect(statesController.value, <MaterialState>{MaterialState.hovered, MaterialState.focused});
expect(count, 8); // adds hovered and pressed - two changes.
// If the text field is rebuilt disabled, then the focused state is
// removed.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
statesController: statesController,
controller: textEditingController,
enabled: false,
),
),
),
),
);
await tester.pumpAndSettle();
expect(statesController.value, <MaterialState>{MaterialState.hovered, MaterialState.disabled});
expect(count, 10); // removes focused and adds disabled - two changes.
await gesture.moveTo(Offset.zero);
await tester.pump();
expect(statesController.value, <MaterialState>{MaterialState.disabled});
expect(count, 11);
// If the text field is rebuilt enabled and in an error state, then the error
// state is added.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
statesController: statesController,
controller: textEditingController,
decoration: const InputDecoration(
errorText: 'error',
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(statesController.value, <MaterialState>{MaterialState.error});
expect(count, 13); // removes disabled and adds error - two changes.
// If the text field is rebuilt without an error, then the error
// state is removed.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
statesController: statesController,
controller: textEditingController,
),
),
),
),
);
await tester.pumpAndSettle();
expect(statesController.value, <MaterialState>{});
expect(count, 14);
});
testWidgets('Disabled TextField statesController', (WidgetTester tester) async {
int count = 0;
void valueChanged() {
count += 1;
}
final MaterialStatesController controller = MaterialStatesController();
controller.addListener(valueChanged);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
statesController: controller,
enabled: false,
),
),
),
),
);
expect(controller.value, <MaterialState>{MaterialState.disabled});
expect(count, 1);
});
testWidgetsWithLeakTracking('Provided style correctly resolves for material states', (WidgetTester tester) async { testWidgetsWithLeakTracking('Provided style correctly resolves for material states', (WidgetTester tester) async {
final TextEditingController controller = _textEditingController( final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure', text: 'Atwater Peel Sherbrooke Bonaventure',
......
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