Mastering WidgetState in Flutter 3.22

Roman Ismagilov
4 min readMay 27, 2024

--

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.

--

--

Roman Ismagilov

Covering some non-obvious nuances of Flutter development in my articles