Skip to content

Commit

Permalink
TextField and TextFormField can use a MaterialStatesController (flutt…
Browse files Browse the repository at this point in the history
…er#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 flutter#133273
  • Loading branch information
Renzo-Olivares authored and caseycrogers committed Dec 29, 2023
1 parent fc10707 commit 89725c9
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 14 deletions.
85 changes: 71 additions & 14 deletions packages/flutter/lib/src/material/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ class TextField extends StatefulWidget {
this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.statesController,
this.obscuringCharacter = '•',
this.obscureText = false,
this.autocorrect = true,
Expand Down Expand Up @@ -457,6 +458,27 @@ class TextField extends StatefulWidget {
/// {@macro flutter.widgets.editableText.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}
final String obscuringCharacter;

Expand Down Expand Up @@ -970,7 +992,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements

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;

Expand Down Expand Up @@ -1055,6 +1081,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
}
_effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled;
_effectiveFocusNode.addListener(_handleFocusChanged);
_initStatesController();
}

bool get _canRequestFocus {
Expand Down Expand Up @@ -1096,6 +1123,20 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
_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
Expand Down Expand Up @@ -1128,6 +1169,8 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
_effectiveFocusNode.removeListener(_handleFocusChanged);
_focusNode?.dispose();
_controller?.dispose();
_statesController.removeListener(_handleStatesControllerChange);
_internalStatesController?.dispose();
super.dispose();
}

Expand Down Expand Up @@ -1172,6 +1215,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
// Rebuild the widget on focus change to show/hide the text selection
// highlight.
});
_statesController.update(MaterialState.focused, _effectiveFocusNode.hasFocus);
}

void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
Expand Down Expand Up @@ -1220,7 +1264,29 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
setState(() {
_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.
Expand All @@ -1246,19 +1312,10 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
}
// 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) {
final ThemeData theme = Theme.of(context);
final TextStyle stateStyle = MaterialStateProperty.resolveAs(theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, _materialState);
final TextStyle providedStyle = MaterialStateProperty.resolveAs(style, _materialState);
final TextStyle stateStyle = MaterialStateProperty.resolveAs(theme.useMaterial3 ? _m3StateInputStyle(context)! : _m2StateInputStyle(context)!, _statesController.value);
final TextStyle providedStyle = MaterialStateProperty.resolveAs(style, _statesController.value);
return providedStyle.merge(stateStyle);
}

Expand All @@ -1275,7 +1332,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements

final ThemeData theme = Theme.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 Brightness keyboardAppearance = widget.keyboardAppearance ?? theme.brightness;
final TextEditingController controller = _effectiveController;
Expand Down Expand Up @@ -1490,7 +1547,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
}
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
widget.mouseCursor ?? MaterialStateMouseCursor.textable,
_materialState,
_statesController.value,
);

final int? semanticsMaxValueLength;
Expand Down
3 changes: 3 additions & 0 deletions packages/flutter/lib/src/material/text_form_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart';

import 'adaptive_text_selection_toolbar.dart';
import 'input_decorator.dart';
import 'material_state.dart';
import 'text_field.dart';
import 'theme.dart';

Expand Down Expand Up @@ -167,6 +168,7 @@ class TextFormField extends FormField<String> {
ui.BoxWidthStyle selectionWidthStyle = ui.BoxWidthStyle.tight,
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ContentInsertionConfiguration? contentInsertionConfiguration,
MaterialStatesController? statesController,
Clip clipBehavior = Clip.hardEdge,
bool scribbleEnabled = true,
bool canRequestFocus = true,
Expand Down Expand Up @@ -212,6 +214,7 @@ class TextFormField extends FormField<String> {
textDirection: textDirection,
textCapitalization: textCapitalization,
autofocus: autofocus,
statesController: statesController,
toolbarOptions: toolbarOptions,
readOnly: readOnly,
showCursor: showCursor,
Expand Down
148 changes: 148 additions & 0 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6822,6 +6822,154 @@ void main() {
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 {
final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
Expand Down

0 comments on commit 89725c9

Please sign in to comment.