WidgetState mapping (#146043)

This pull request implements [enhanced enum](https://dart.dev/language/enums#declaring-enhanced-enums) features for the new `WidgetState` enum, in order to improve the developer experience when creating and using `WidgetStateProperty` objects.

`WidgetState` now has a `.matchesSet()` method:

```dart
// identical to "states.contains(WidgetState.error)"
final bool hasError = WidgetState.error.isSatisfiedBy(states);
```

This addition allows for wide variety of `WidgetStateProperty` objects to be constructed in a simple manner.

<br><br>

```dart
// before
final style = MaterialStateTextStyle.resolveWith((states) {
  if (states.contains(MaterialState.error)) {
    return TextStyle(color: Colors.red);
  } else if (states.contains(MaterialState.focused)) {
    return TextStyle(color: Colors.blue);
  }
  return TextStyle(color: Colors.black);
});

// after
final style = WidgetStateTextStyle.fromMap({
  WidgetState.error:   TextStyle(color: Colors.red),
  WidgetState.focused: TextStyle(color: Colors.blue),
  WidgetState.any:     TextStyle(color: Colors.black), // "any" is a static const member, not an enum value
});
```

```dart
// before
final color = MaterialStateProperty.resolveWith((states) {
  if (states.contains(MaterialState.focused)) {
    return Colors.blue;
  } else if (!states.contains(MaterialState.disabled)) {
    return Colors.black;
  }
  return null;
});

// after
final color = WidgetStateProperty<Color?>.fromMap({
  WidgetState.focused:   Colors.blue,
  ~WidgetState.disabled: Colors.black,
});
```

```dart
// before
const activeStates = [MaterialState.selected, MaterialState.focused, MaterialState.scrolledUnder];

final color = MaterialStateColor.resolveWith((states) {
  if (activeStates.any(states.contains)) {
    if (states.contains(MaterialState.hovered) {
      return Colors.blueAccent;
    }
    return Colors.blue;
  }
  return Colors.black;
});

// after
final active = WidgetState.selected | WidgetState.focused | WidgetState.scrolledUnder;

final color = WidgetStateColor.fromMap({
  active & WidgetState.hovered: Colors.blueAccent,
  active:  Colors.blue,
  ~active: Colors.black,
});
```

<br>

(fixes #146042, and also fixes #143488)
This commit is contained in:
Nate Wilson 2024-07-18 10:48:06 -06:00 committed by GitHub
parent af0e01c370
commit db80f4e713
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 305 additions and 10 deletions

View File

@ -8,6 +8,89 @@ import 'package:flutter/services.dart';
// Examples can assume:
// late BuildContext context;
// late Set<WidgetState> states;
/// This class allows [WidgetState] enum values to be combined
/// using [WidgetStateOperators].
///
/// A [Map] with [WidgetStatesConstraint] objects as keys can be used
/// in the [WidgetStateProperty.fromMap] constructor to resolve to
/// one of its values, based on the first key that [isSatisfiedBy]
/// the current set of states.
///
/// {@macro flutter.widgets.WidgetStateMap}
abstract interface class WidgetStatesConstraint {
/// Whether the provided [states] satisfy this object's criteria.
///
/// If the constraint is a single [WidgetState] object,
/// it's satisfied by the set if the set contains the object.
///
/// The constraint can also be created using one or more operators, for example:
///
/// {@template flutter.widgets.WidgetStatesConstraint.isSatisfiedBy}
/// ```dart
/// final WidgetStatesConstraint constraint = WidgetState.focused | WidgetState.hovered;
/// ```
///
/// In the above case, `constraint.isSatisfiedBy(states)` is equivalent to:
///
/// ```dart
/// states.contains(WidgetState.focused) || states.contains(WidgetState.hovered);
/// ```
/// {@endtemplate}
bool isSatisfiedBy(Set<WidgetState> states);
}
// A private class, used in [WidgetStateOperators].
class _WidgetStateOperation implements WidgetStatesConstraint {
const _WidgetStateOperation(this._isSatisfiedBy);
final bool Function(Set<WidgetState> states) _isSatisfiedBy;
@override
bool isSatisfiedBy(Set<WidgetState> states) => _isSatisfiedBy(states);
}
/// These operators can be used inside a [WidgetStateMap] to combine states
/// and find a match.
///
/// Example:
///
/// {@macro flutter.widgets.WidgetStatesConstraint.isSatisfiedBy}
///
/// Since enums can't extend other classes, [WidgetState] instead `implements`
/// the [WidgetStatesConstraint] interface. This `extension` ensures that
/// the operators can be used without being directly inherited.
extension WidgetStateOperators on WidgetStatesConstraint {
/// Combines two [WidgetStatesConstraint] values using logical "and".
WidgetStatesConstraint operator &(WidgetStatesConstraint other) {
return _WidgetStateOperation(
(Set<WidgetState> states) => isSatisfiedBy(states) && other.isSatisfiedBy(states),
);
}
/// Combines two [WidgetStatesConstraint] values using logical "or".
WidgetStatesConstraint operator |(WidgetStatesConstraint other) {
return _WidgetStateOperation(
(Set<WidgetState> states) => isSatisfiedBy(states) || other.isSatisfiedBy(states),
);
}
/// Takes a [WidgetStatesConstraint] and applies the logical "not".
WidgetStatesConstraint operator ~() {
return _WidgetStateOperation(
(Set<WidgetState> states) => !isSatisfiedBy(states),
);
}
}
// A private class, used to create [WidgetState.any].
class _AlwaysMatch implements WidgetStatesConstraint {
const _AlwaysMatch();
@override
bool isSatisfiedBy(Set<WidgetState> states) => true;
}
/// Interactive states that some of the widgets can take on when receiving input
/// from the user.
@ -39,7 +122,7 @@ import 'package:flutter/services.dart';
/// `WidgetStateProperty` which is used in APIs that need to accept either
/// a [TextStyle] or a [WidgetStateProperty<TextStyle>].
/// {@endtemplate}
enum WidgetState {
enum WidgetState implements WidgetStatesConstraint {
/// The state when the user drags their mouse cursor over the given widget.
///
/// See: https://material.io/design/interaction/states.html#hover.
@ -89,7 +172,17 @@ enum WidgetState {
/// The state when the widget has entered some form of invalid state.
///
/// See https://material.io/design/interaction/states.html#usage.
error,
error;
/// {@template flutter.widgets.WidgetState.any}
/// To prevent a situation where each [WidgetStatesConstraint]
/// isn't satisfied by the given set of states, consier adding
/// [WidgetState.any] as the final [WidgetStateMap] key.
/// {@endtemplate}
static const WidgetStatesConstraint any = _AlwaysMatch();
@override
bool isSatisfiedBy(Set<WidgetState> states) => states.contains(this);
}
/// Signature for the function that returns a value of type `T` based on a given
@ -112,6 +205,7 @@ typedef WidgetPropertyResolver<T> = T Function(Set<WidgetState> states);
/// 1. Create a subclass of [WidgetStateColor] and implement the abstract `resolve` method.
/// 2. Use [WidgetStateColor.resolveWith] and pass in a callback that
/// will be used to resolve the color in the given states.
/// 3. Use [WidgetStateColor.fromMap] to assign a value using a [WidgetStateMap].
///
/// If a [WidgetStateColor] is used for a property or a parameter that doesn't
/// support resolving [WidgetStateProperty<Color>]s, then its default color
@ -160,7 +254,17 @@ abstract class WidgetStateColor extends Color implements WidgetStateProperty<Col
///
/// The given callback parameter must return a non-null color in the default
/// state.
static WidgetStateColor resolveWith(WidgetPropertyResolver<Color> callback) => _WidgetStateColor(callback);
factory WidgetStateColor.resolveWith(WidgetPropertyResolver<Color> callback) = _WidgetStateColor;
/// Creates a [WidgetStateColor] from a [WidgetStateMap<Color>].
///
/// {@macro flutter.widgets.WidgetStateProperty.fromMap}
///
/// If used as a regular color, the first key that matches an empty
/// [Set] of [WidgetState]s will be selected.
///
/// {@macro flutter.widgets.WidgetState.any}
factory WidgetStateColor.fromMap(WidgetStateMap<Color> map) = _WidgetStateColorMapper;
/// Returns a [Color] that's to be used when a component is in the specified
/// state.
@ -182,6 +286,18 @@ class _WidgetStateColor extends WidgetStateColor {
Color resolve(Set<WidgetState> states) => _resolve(states);
}
class _WidgetStateColorMapper extends WidgetStateColor {
_WidgetStateColorMapper(this.map)
: super(_WidgetStateMapper<Color>(map).resolve(_defaultStates).value);
final WidgetStateMap<Color> map;
static const Set<WidgetState> _defaultStates = <WidgetState>{};
@override
Color resolve(Set<WidgetState> states) => _WidgetStateMapper<Color>(map).resolve(states);
}
class _WidgetStateColorTransparent extends WidgetStateColor {
const _WidgetStateColorTransparent() : super(0x00000000);
@ -348,8 +464,30 @@ abstract class WidgetStateBorderSide extends BorderSide implements WidgetStatePr
/// ```
const factory WidgetStateBorderSide.resolveWith(WidgetPropertyResolver<BorderSide?> callback) = _WidgetStateBorderSide;
/// Returns a [BorderSide] that's to be used when a Material component is
/// in the specified state. Return null to defer to the default value of the
/// Creates a [WidgetStateBorderSide] from a [WidgetStateMap].
///
/// {@macro flutter.widgets.WidgetStateProperty.fromMap}
///
/// If used as a regular [BorderSide], the first key that matches an empty
/// [Set] of [WidgetState]s will be selected.
///
/// Example:
///
/// ```dart
/// const Chip(
/// label: Text('Transceiver'),
/// side: WidgetStateBorderSide.fromMap(<WidgetStatesConstraint, BorderSide?>{
/// WidgetState.selected: BorderSide(color: Colors.red),
/// // returns null if not selected, deferring to default theme/widget value.
/// }),
/// ),
/// ```
///
/// {@macro flutter.widgets.WidgetState.any}
const factory WidgetStateBorderSide.fromMap(WidgetStateMap<BorderSide?> map) = _WidgetBorderSideMapper;
/// Returns a [BorderSide] that's to be used when a Widget is in the
/// specified state. Return null to defer to the default value of the
/// widget or theme.
@override
BorderSide? resolve(Set<WidgetState> states);
@ -401,6 +539,15 @@ class _WidgetStateBorderSide extends WidgetStateBorderSide {
BorderSide? resolve(Set<WidgetState> states) => _resolve(states);
}
class _WidgetBorderSideMapper extends WidgetStateBorderSide {
const _WidgetBorderSideMapper(this.map);
final WidgetStateMap<BorderSide?> map;
@override
BorderSide? resolve(Set<WidgetState> states) => _WidgetStateMapper<BorderSide?>(map).resolve(states);
}
/// Defines an [OutlinedBorder] whose value depends on a set of [WidgetState]s
/// which represent the interactive state of a component.
///
@ -452,6 +599,7 @@ abstract class WidgetStateOutlinedBorder extends OutlinedBorder implements Widge
/// 1. Create a subclass of [WidgetStateTextStyle] and implement the abstract `resolve` method.
/// 2. Use [WidgetStateTextStyle.resolveWith] and pass in a callback that
/// will be used to resolve the color in the given states.
/// 3. Use [WidgetStateTextStyle.fromMap] to assign a style using a [WidgetStateMap].
///
/// If a [WidgetStateTextStyle] is used for a property or a parameter that doesn't
/// support resolving [WidgetStateProperty<TextStyle>]s, then its default color
@ -480,6 +628,16 @@ abstract class WidgetStateTextStyle extends TextStyle implements WidgetStateProp
/// state.
const factory WidgetStateTextStyle.resolveWith(WidgetPropertyResolver<TextStyle> callback) = _WidgetStateTextStyle;
/// Creates a [WidgetStateTextStyle] from a [WidgetStateMap].
///
/// {@macro flutter.widgets.WidgetStateProperty.fromMap}
///
/// If used as a regular text style, the first key that matches an empty
/// [Set] of [WidgetState]s will be selected.
///
/// {@macro flutter.widgets.WidgetState.any}
const factory WidgetStateTextStyle.fromMap(WidgetStateMap<TextStyle> map) = _WidgetTextStyleMapper;
/// Returns a [TextStyle] that's to be used when a component is in the
/// specified state.
@override
@ -495,6 +653,15 @@ class _WidgetStateTextStyle extends WidgetStateTextStyle {
TextStyle resolve(Set<WidgetState> states) => _resolve(states);
}
class _WidgetTextStyleMapper extends WidgetStateTextStyle {
const _WidgetTextStyleMapper(this.map);
final WidgetStateMap<TextStyle> map;
@override
TextStyle resolve(Set<WidgetState> states) => _WidgetStateMapper<TextStyle>(map).resolve(states);
}
/// Interface for classes that [resolve] to a value of type `T` based
/// on a widget's interactive "state", which is defined as a set
/// of [WidgetState]s.
@ -519,12 +686,26 @@ class _WidgetStateTextStyle extends WidgetStateTextStyle {
/// `WidgetStateProperty`.
/// {@macro flutter.widgets.WidgetStateProperty.implementations}
abstract class WidgetStateProperty<T> {
/// Returns a value of type `T` that depends on [states].
/// This abstract constructor allows extending the class.
///
/// Widgets like [TextButton] and [ElevatedButton] apply this method to their
/// current [WidgetState]s to compute colors and other visual parameters
/// at build time.
T resolve(Set<WidgetState> states);
/// [WidgetStateProperty] is designed as an interface, so this constructor
/// is only needed for backward compatibility.
WidgetStateProperty();
/// Creates a property that resolves using a [WidgetStateMap].
///
/// {@template flutter.widgets.WidgetStateProperty.fromMap}
/// This constructor's [resolve] method finds the first [MapEntry] whose
/// key is satisfied by the set of states, and returns its associated value.
/// {@endtemplate}
///
/// Returns `null` if no keys match, or if [T] is non-nullable,
/// the method throws an [ArgumentError].
///
/// {@macro flutter.widgets.WidgetState.any}
///
/// {@macro flutter.widgets.WidgetStateMap}
const factory WidgetStateProperty.fromMap(WidgetStateMap<T> map) = _WidgetStateMapper<T>;
/// Resolves the value for the given set of states if `value` is a
/// [WidgetStateProperty], otherwise returns the value itself.
@ -567,6 +748,13 @@ abstract class WidgetStateProperty<T> {
}
return _LerpProperties<T>(a, b, t, lerpFunction);
}
/// Returns a value of type `T` that depends on [states].
///
/// Widgets like [TextButton] and [ElevatedButton] apply this method to their
/// current [WidgetState]s to compute colors and other visual parameters
/// at build time.
T resolve(Set<WidgetState> states);
}
class _LerpProperties<T> implements WidgetStateProperty<T?> {
@ -594,6 +782,96 @@ class _WidgetStatePropertyWith<T> implements WidgetStateProperty<T> {
T resolve(Set<WidgetState> states) => _resolve(states);
}
/// A [Map] used to resolve to a single value of type `T` based on
/// the current set of Widget states.
///
/// {@template flutter.widgets.WidgetStateMap}
/// Example:
///
/// ```dart
/// // This WidgetStateMap<Color?> resolves to null if no keys match.
/// WidgetStateProperty<Color?>.fromMap(<WidgetStatesConstraint, Color?>{
/// WidgetState.error: Colors.red,
/// WidgetState.hovered & WidgetState.focused: Colors.blueAccent,
/// WidgetState.focused: Colors.blue,
/// ~WidgetState.disabled: Colors.black,
/// });
///
/// // The same can be accomplished with a WidgetPropertyResolver,
/// // but it's more verbose:
/// WidgetStateProperty.resolveWith<Color?>((Set<WidgetState> states) {
/// if (states.contains(WidgetState.error)) {
/// return Colors.red;
/// } else if (states.contains(WidgetState.hovered) && states.contains(WidgetState.focused)) {
/// return Colors.blueAccent;
/// } else if (states.contains(WidgetState.focused)) {
/// return Colors.blue;
/// } else if (!states.contains(WidgetState.disabled)) {
/// return Colors.black;
/// }
/// return null;
/// });
/// ```
///
/// A widget state combination can be stored in a variable,
/// and [WidgetState.any] can be used for non-nullable types to ensure
/// that there's a match:
///
/// ```dart
/// final WidgetStatesConstraint selectedError = WidgetState.selected & WidgetState.error;
///
/// final WidgetStateProperty<Color> color = WidgetStateProperty<Color>.fromMap(
/// <WidgetStatesConstraint, Color>{
/// selectedError & WidgetState.hovered: Colors.redAccent,
/// selectedError: Colors.red,
/// WidgetState.any: Colors.black,
/// },
/// );
///
/// // The (more verbose) WidgetPropertyResolver implementation:
/// final WidgetStateProperty<Color> colorResolveWith = WidgetStateProperty.resolveWith<Color>(
/// (Set<WidgetState> states) {
/// if (states.containsAll(<WidgetState>{WidgetState.selected, WidgetState.error})) {
/// if (states.contains(WidgetState.hovered)) {
/// return Colors.redAccent;
/// }
/// return Colors.red;
/// }
/// return Colors.black;
/// },
/// );
/// ```
/// {@endtemplate}
typedef WidgetStateMap<T> = Map<WidgetStatesConstraint, T>;
// A private class, used to create the [WidgetStateProperty.fromMap] constructor.
class _WidgetStateMapper<T> implements WidgetStateProperty<T> {
const _WidgetStateMapper(this.map);
final WidgetStateMap<T> map;
@override
T resolve(Set<WidgetState> states) {
for (final MapEntry<WidgetStatesConstraint, T> entry in map.entries) {
if (entry.key.isSatisfiedBy(states)) {
return entry.value;
}
}
try {
return null as T;
} on TypeError {
throw ArgumentError(
'The current set of material states is $states.\n'
'None of the provided map keys matched this set, '
'and the type "$T" is non-nullable.\n'
'Consider using "WidgetStateProperty<$T?>.fromMap()", '
'or adding the "WidgetState.any" key to this map.',
);
}
}
}
/// Convenience class for creating a [WidgetStateProperty] that
/// resolves to the given value for all states.
///

View File

@ -20,6 +20,23 @@ void main() {
expect(value.resolve(<WidgetState>{WidgetState.error}), WidgetState.error);
});
test('WidgetStateProperty.map()', () {
final WidgetStatesConstraint active = WidgetState.hovered | WidgetState.focused | WidgetState.pressed;
final WidgetStateProperty<String?> value = WidgetStateProperty<String?>.fromMap(
<WidgetStatesConstraint, String?>{
active & WidgetState.error: 'active error',
WidgetState.disabled | WidgetState.error: 'kinda sus',
~(WidgetState.dragged | WidgetState.selected) & ~active: 'this is boring',
active: 'active',
},
);
expect(value.resolve(<WidgetState>{WidgetState.focused, WidgetState.error}), 'active error');
expect(value.resolve(<WidgetState>{WidgetState.scrolledUnder}), 'this is boring');
expect(value.resolve(<WidgetState>{WidgetState.disabled}), 'kinda sus');
expect(value.resolve(<WidgetState>{WidgetState.hovered}), 'active');
expect(value.resolve(<WidgetState>{WidgetState.dragged}), null);
});
test('WidgetStateProperty.all()', () {
final WidgetStateProperty<int> value = WidgetStateProperty.all<int>(123);
expect(value.resolve(<WidgetState>{WidgetState.hovered}), 123);