Advanced Widget State Control
This tutorial covers scenarios where Mix’s automatic state management isn’t enough. You’ll learn to use WidgetStatesController for programmatic control, custom states like selected, shared state across widgets, and low-level style resolution with StyleBuilder.
Prerequisites
Before starting, you should be familiar with:
- Dynamic Styling — how
onHovered,onPressed,onFocused, and other built-in variants work - Flutter’s
StatefulWidgetlifecycle andWidgetStatesController
The Dynamic Styling guide covers automatic state management — variants that work out of the box with any Mix widget. This tutorial picks up where that guide leaves off.
When You Need Manual Control
Automatic variants handle the common cases: hover, press, focus, disabled. But you need WidgetStatesController when:
- You want to toggle custom states like
selectedorerror - You need programmatic control (setting states from timers, streams, or callbacks)
- You want to share a single controller across multiple widgets
- You’re building a custom interactive widget with non-standard gesture handling
WidgetStatesController API
WidgetStatesController is Flutter’s standard controller for tracking widget states. Mix extends it with convenience setters and getters:
final controller = WidgetStatesController();
// Setters
controller.pressed = true;
controller.hovered = true;
controller.focused = true;
controller.disabled = true;
controller.selected = true;
controller.dragged = true;
controller.error = true;
// Getters
controller.pressed; // bool
controller.selected; // bool
// Generic API
controller.update(WidgetState.selected, true);
controller.has(WidgetState.selected); // boolThe controller is a Listenable — it notifies listeners whenever its state set changes.
Always dispose the controller in your
State.dispose()method to avoid memory leaks.
Passing a Controller to Pressable
The simplest way to use manual state control is to pass a controller directly to Pressable. This lets you read and write states while Pressable still handles gesture detection for you:
class SelectableBox extends StatefulWidget {
const SelectableBox({super.key});
@override
State<SelectableBox> createState() => _SelectableBoxState();
}
class _SelectableBoxState extends State<SelectableBox> {
final controller = WidgetStatesController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = BoxStyler()
.color(Colors.red)
.height(100)
.width(100)
.borderRadius(.circular(10))
.variant(ContextVariant.widgetState(.selected), .color(Colors.blue));
return Pressable(
onPress: () {
final isSelected = controller.has(.selected);
controller.update(.selected, !isSelected);
},
controller: controller,
child: Box(style: style),
);
}
}Key points:
Pressablestill tracks hover, press, and focus automatically- The controller gives you access to toggle
selectedon tap .variant(ContextVariant.widgetState(.selected), ...)applies styles when the selected state is active- Unlike
onHoveredoronPressed, there is no.onSelected()shorthand — use.variant()withContextVariant.widgetState()for states beyond the standard interaction set
Custom States with .variant()
The onHovered, onPressed, onFocused, and onDisabled shortcuts cover common interaction states. For other states — selected, dragged, error — use .variant() with ContextVariant.widgetState():
final style = BoxStyler()
.color(Colors.grey)
.variant(ContextVariant.widgetState(.selected), .color(Colors.blue))
.variant(ContextVariant.widgetState(.error), .color(Colors.red));This works with any WidgetState value. The variant activates when the corresponding state is present in the nearest WidgetStatesController.
Selected Toggle Example
Here’s a more complete example — a toggle button that animates between selected and unselected states, with coordinated styles across the container and its label:
class ToggleButton extends StatefulWidget {
const ToggleButton({super.key});
@override
State<ToggleButton> createState() => _ToggleButtonState();
}
class _ToggleButtonState extends State<ToggleButton> {
final controller = WidgetStatesController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final boxStyle = BoxStyler()
.height(60)
.width(120)
.borderRadius(.circular(30))
.color(Colors.grey.shade200)
.border(.color(Colors.grey.shade300).width(2))
.animate(.spring(300.ms))
.variant(
ContextVariant.widgetState(.selected),
.color(Colors.blue.shade500)
.border(.color(Colors.blue.shade600).width(2))
.shadow(
.color(Colors.blue.shade200)
.blurRadius(10)
.spreadRadius(2),
),
);
final textStyle = TextStyler()
.fontSize(16)
.fontWeight(.w600)
.color(Colors.grey.shade700)
.variant(ContextVariant.widgetState(.selected), .color(Colors.white));
return Pressable(
onPress: () {
final isSelected = controller.has(.selected);
controller.update(.selected, !isSelected);
},
controller: controller,
child: Box(
style: boxStyle,
child: Center(
child: StyledText(
controller.has(.selected) ? 'Selected' : 'Select Me',
style: textStyle,
),
),
),
);
}
}Both the container and the text respond to the same .selected state because they share the same WidgetStatesController through Pressable. The .animate(.spring(300.ms)) on the box style produces a smooth spring transition when toggling.
Driving State from External Sources
Because WidgetStatesController is just a Listenable, you can drive it from any source — a parent widget, a stream, a timer, or business logic:
class ExternallyControlledBox extends StatefulWidget {
final bool selected;
final VoidCallback onPressed;
const ExternallyControlledBox({
super.key,
required this.selected,
required this.onPressed,
});
@override
State<ExternallyControlledBox> createState() =>
_ExternallyControlledBoxState();
}
class _ExternallyControlledBoxState extends State<ExternallyControlledBox> {
late final WidgetStatesController controller;
@override
void initState() {
super.initState();
controller = WidgetStatesController();
controller.selected = widget.selected;
}
@override
void didUpdateWidget(ExternallyControlledBox oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
controller.selected = widget.selected;
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = BoxStyler()
.color(Colors.grey)
.height(60)
.width(120)
.borderRadius(.circular(8))
.animate(.easeInOut(300.ms))
.variant(ContextVariant.widgetState(.selected), .color(Colors.black));
return Pressable(
onPress: widget.onPressed,
controller: controller,
child: Box(style: style),
);
}
}The parent controls selected via a prop, and the widget syncs the controller in didUpdateWidget. Gesture handling (hover, press) still works automatically through Pressable.
Low-Level Control with StyleBuilder
For full control over how styles are resolved and rendered — without Pressable — use StyleBuilder directly with a controller. This is useful when you need custom gesture handling or want to build a non-standard interactive widget.
class CustomInteraction extends StatefulWidget {
const CustomInteraction({super.key});
@override
State<CustomInteraction> createState() => _CustomInteractionState();
}
class _CustomInteractionState extends State<CustomInteraction> {
final controller = WidgetStatesController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
BoxStyler get style => BoxStyler()
.color(Colors.red)
.size(100, 100)
.onHovered(.color(Colors.blue))
.onPressed(.color(Colors.green));
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => controller.pressed = true,
onTapUp: (_) => controller.pressed = false,
onTapCancel: () => controller.pressed = false,
child: StyleBuilder(
style: style,
controller: controller,
builder: (context, spec) {
return Box(styleSpec: StyleSpec(spec: spec));
},
),
);
}
}How StyleBuilder Works
StyleBuilder resolves your style definition against the current controller state and passes the fully resolved spec to the builder function:
style— the style (with variants) to resolvecontroller— theWidgetStatesControllerwhose state set determines which variants are activebuilder(context, spec)— receives the resolved spec reflecting the current state (e.g., ifcontroller.pressedistrue, the spec already includes theonPressedoverrides)
The StyleSpec(spec: spec) wrapper passes the pre-resolved spec directly to Box, bypassing the normal style resolution that Box would do on its own. This is necessary because StyleBuilder has already resolved the style for you.
Inheritable Styles
StyleBuilder has an inheritable parameter. When set to true, the resolved style is provided to descendant widgets via StyleProvider, letting child StyleBuilder instances inherit and merge with the parent’s style:
StyleBuilder(
style: parentStyle,
controller: controller,
inheritable: true,
builder: (context, spec) {
return StyleBuilder(
style: childStyle, // merges with parentStyle
builder: (context, childSpec) {
return Box(styleSpec: StyleSpec(spec: childSpec));
},
);
},
)Listening to State Changes
Since WidgetStatesController extends ChangeNotifier, you can listen to state changes for side effects — logging, analytics, triggering animations:
@override
void initState() {
super.initState();
controller = WidgetStatesController();
controller.addListener(_onStateChanged);
}
void _onStateChanged() {
if (controller.selected) {
// Trigger a side effect when selected
analytics.track('item_selected');
}
}
@override
void dispose() {
controller.removeListener(_onStateChanged);
controller.dispose();
super.dispose();
}Summary
| Pattern | When to use |
|---|---|
onHovered / onPressed / onFocused on any widget | Standard interaction states — covered in Dynamic Styling |
Pressable + controller | Toggle custom states (selected, error) while keeping automatic gesture handling |
.variant(ContextVariant.widgetState(...)) | Style any WidgetState beyond the on* shortcuts |
StyleBuilder + controller | Full control over gesture detection and style resolution |
inheritable: true on StyleBuilder | Pass resolved styles down to child StyleBuilder instances |
controller.addListener(...) | React to state changes with side effects |
Next Steps
- Building a Design System Widget — see
Pressable,StyleBuilder, and variant enums used together in a full button component - Creating Context Variants — create custom
ContextVariantinstances based onBuildContext - Animations — add implicit and spring animations to state transitions