Mastering WidgetState in Flutter 3.22
In this article we are going to have an overview of how WidgetState
works, understand how state properties are working and how to use them in our own widgets to keep it as idiomatically close to Flutter as possible by separating style and layout.
Before Flutter 3.22 it was called MaterialState
. That name is still available, but will be deprecated at some point in the future.
switchTheme: SwitchThemeData(
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
return states.contains(WidgetState.selected)
? Colors.white
: null;
}),
trackOutlineWidth: WidgetStateProperty.all(1.0),
)),
You have probably have seen something similar in the code when configuring the app theme. It works the way that a widget decides what state it is currently in, while at style level we resolve what color is going to be used. Generally speaking, a property could be of any type, not just colors. In Flutter we can configure MouseCursor
, Border
, BorderSide
, TextStyle
or a number this way as well and we can extend the list by adding custom properties.
1. Creating a style using WidgetStateProperty
Styles could be specified in app’s theme or in a widget itself.
This flexibility is especially useful when developing for web, because this way we can elegantly handle hover state:
ButtonStyle(
side: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return const BorderSide(color: Colors.black);
}
if (states.contains(WidgetState.hovered)) {
return const BorderSide(color: Colors.deepPurple);
}
}),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return Colors.black12;
}
return Colors.transparent;
})
)
Pretty simple. Converting a state to a property. Note, that a widget might have several states at the same time. For example, it could be both hovered
and pressed
.
Another benefit of this approach is that you can alter differences of dark and light themes without modifying widget code. Say, you want to add a border on hovered only for light mode, while no border at all in dark mode. Then just change the style in a corresponding theme.
2. Listening to WidgetStatesController in a parent widget
Now let’s see, what can we do with states. Flutter provides us with a WidgetStatesController
, which is assigned to a widget and allows us for modifying and listening to its WidgetState
.
Imagine that you have to develop such UI logic: when user hovers a button, the whole card should drop a shadow. Should be straight-forward:
final _controller = WidgetStatesController();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: _controller,
builder: (context, states, _) {
return Card(
elevation: states.contains(WidgetState.hovered) ? 4 : 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text("Text"),
TextButton(
statesController: _controller,
onPressed: _onPressed,
child: const Text("Button"),
)
],
),
),
);
}
);
}
WidgetStatesController
extends ValueNotifier
, which means that we can subscribe on its updates using a ValueListenableBuilder
(or a ListenableBuilder
). Then we can pass an elevation value that depends on TextButton
’s WidgetState
.
3. Creating a widget with a custom style that utilizes WidgetStateProperties.
As a basis, we take the TapArea widget from one of the previous articles. There were different implementations of a tappable area for Android and iOS. Let’s add a web implementation that will highlight a child on hover and fade it out on click.
First of all, we need to create a ThemeData and Style classes:
class TapAreaThemeData {
final TapAreaStyle style;
TapAreaThemeData(this.style);
}
class TapAreaStyle {
final WidgetStateProperty<double?> opacity;
final WidgetStateProperty<double?> foregroundHighlight;
TapAreaStyle({required this.opacity, required this.foregroundHighlight});
}
Then we update our widget, by adding a WidgetStatesController and then updating its value on gesture and mouse events.
To make widgets update on controller’s changes, we wrap everything in a ListenableBuilder. Every time value of controller changes, properties are resolved again:
return ListenableBuilder(
listenable: _statesController,
builder: (context, _) {
final resolvedOpacity =
widget.style?.opacity.resolve(_statesController.value) ?? 1.0;
final resolvedHighlightOpacity = widget.style?.foregroundHighlight
.resolve(_statesController.value) ?? 1.0;
return ...
}
);
MouseRegion
has callbacks that detect events when mouse enters widget’s area.
return MouseRegion(
onEnter: (_) {
_statesController.update(WidgetState.hovered, true);
},
onExit: (_) {
_statesController.update(WidgetState.hovered, false);
},
child: ...
);
Same applies to GestureDetector
:
child: GestureDetector(
onTapDown: (_) {
_statesController.update(WidgetState.pressed, true);
},
onTapCancel: () {
_statesController.update(WidgetState.pressed, false);
},
onTap: () {
_statesController.update(WidgetState.pressed, false);
widget.onTap!();
},
child: ...
)
And then we apply content opacity and foreground color:
child: ColoredBox(
color: Colors.deepPurple.withOpacity(resolvedHighlightOpacity),
child: Opacity(
opacity: resolvedOpacity,
child: content,
),
)
Finally, we define our theme data and style:
TapAreaStyle(
opacity: WidgetStateProperty.resolveWith(
(states) {
if (states.contains(WidgetState.pressed)) {
return 0.5;
}
if (states.contains(WidgetState.hovered)) {
return 0.9;
}
return 1.0;
},
),
foregroundHighlight: WidgetStateProperty.resolveWith(
(states) {
if (states.contains(WidgetState.hovered)) {
return 0.1;
}
return 0.0;
},
),
)
Resulting widget:
Hope you’ve found this article useful. I will update it with more techniques whenever I find something useful. Follow me on Twitter to get the latest updates. If you want to read the full code, you can check the repository.