Skip to Content
DocsTutorialsAdvanced Widget State Control

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 StatefulWidget lifecycle and WidgetStatesController

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 selected or error
  • 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); // bool

The 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), ); } }
Resolving preview metadata...

Key points:

  • Pressable still tracks hover, press, and focus automatically
  • The controller gives you access to toggle selected on tap
  • .variant(ContextVariant.widgetState(.selected), ...) applies styles when the selected state is active
  • Unlike onHovered or onPressed, there is no .onSelected() shorthand — use .variant() with ContextVariant.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 resolve
  • controller — the WidgetStatesController whose state set determines which variants are active
  • builder(context, spec) — receives the resolved spec reflecting the current state (e.g., if controller.pressed is true, the spec already includes the onPressed overrides)

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

PatternWhen to use
onHovered / onPressed / onFocused on any widgetStandard interaction states — covered in Dynamic Styling
Pressable + controllerToggle custom states (selected, error) while keeping automatic gesture handling
.variant(ContextVariant.widgetState(...))Style any WidgetState beyond the on* shortcuts
StyleBuilder + controllerFull control over gesture detection and style resolution
inheritable: true on StyleBuilderPass resolved styles down to child StyleBuilder instances
controller.addListener(...)React to state changes with side effects

Next Steps

Last updated on