In standard application screens, Flutter's reactive equation—UI = f(state)—is remarkably clean. A network request transitions from loading to loaded, a form toggles a validation error, or a navigation tab updates an integer index. Events arrive intermittently, transitions happen in a single discrete step, and rebuilding the affected subtree once per transition easily stays within frame budgets.
Continuous, multi-phase gestures obey a different set of rules.
On a 120Hz display, the entire input, layout, and paint pipeline has 8.3 milliseconds per frame. Within a single unbroken pointer drag, a widget can cross multiple coordinate frames, toggle between competing spatial targets, evaluate hysteresis thresholds, and hand off momentum to a physics simulation—all without the user ever lifting their finger.
Consider a real-world example from flutter_notification_queue (FNQ), an overlay-based notification engine for Flutter. A single notification card configured with hybrid drag interaction (ReorderAndRelocate) can pass through five distinct operational phases inside one continuous gesture:
- Pointer down & lift: Capture the card's initial bounds (
cardRect), the surrounding queue stack bounds (containerRect), and the pointer anchor (pointerPosition), apply tactile lift scaling, and pause auto-dismiss timers. - Intra-stack reordering: Track pointer proximity across dynamic slot zones (
SlotDropZone) inside the queue stack, stabilizing adjacent slot selection with a 40-pixel hysteresis gravity well so pointer jitter never flickers between neighboring slots. - Container boundary escape: Once the pointer moves outside the queue container's inflated bounds, collapse the stack-reorder indicators and swap the overlay layer to screen-wide relocation targets (
RelocationTargets). - Boundary re-entry: If the user changes their mind and drags back inside the queue perimeter before releasing, transition smoothly back into stack reordering.
- Release & settle: Commit a slot reorder, relocate the notification to another screen corner, trigger a directional intent (
Dismiss,Snooze,Pin, orCustomAction), or snap the card back to rest via a velocity-projectedSpringSimulation—which itself must stop cleanly if the user grabs the card mid-flight.
When an interaction carries this many interlocking rules, spreading boolean flags across StatefulWidget callbacks turns subtle edge cases into permanent fragility.
Foundational invariant: Gesture transition logic must be discrete, mutually exclusive, and independent of the widget tree that renders it.
1. The Boolean Flag Trap
When adding drag behaviors incrementally to a Flutter widget, the path of least resistance is storing phase flags directly on the host State<T> class:
At two flags, this feels effortless. By five flags, three structural failures emerge simultaneously:
Combinatorial Explosion and Impossible States
Five boolean variables produce 25 = 32 distinct state combinations, even when the physical interaction only has half a dozen valid phases. The type system cannot stop _isReordering and _isRelocating from both being true at once, or prevent _isDragging from remaining true while _isSnapBackAnimating is running after an interrupted release.
Every pointer callback (onDragStarted, onDragUpdate, onDragEnd, and spring completion .then(...)) must now carry defensive guard clauses (if (_isDragging && !_isSnapBackAnimating && mounted)) to patch over impossible combinations that should never have been representable in the first place.
120Hz Rebuild Churn
Calling setState() inside onDragUpdate marks the host StatefulWidget dirty on every pointer move event. At 120Hz, that triggers unnecessary widget-tree diffing across card decorations, timers, and layout wrappers that do not depend on pointer coordinates—pushing avoidable work into an 8.3ms frame budget.
Coupling Logic to the Rendering Pipeline
When transition math lives directly inside widget callbacks alongside context.findRenderObject(), OverlayPortalController, and TickerProvider, the state rules cannot execute without Flutter's rendering harness. Verifying a boundary edge case—such as dragging out of a stack, crossing back inside, and interrupting a snap-back spring—requires spinning up a full widget tree under WidgetTester and pumping layout frames.
2. Modeling Gestures as a Finite State Machine
A Finite State Machine (FSM) replaces implicit flag combinations with an explicit mathematical model:
- A finite set of mutually exclusive states: The system is in exactly one phase at any instant.
- A well-defined input alphabet: Transitions occur only through explicit lifecycle actions (
lift,update,settle,reset). - Deterministic transition rules: Given
(CurrentState, Input), the next state and its valid invariants are fixed.
In FNQ's interaction layer, every drag transaction moves through the following state graph:
Notice the structural difference from boolean flags:
- Mutually exclusive modes:
GestureReorderingandGestureRelocatingcannot exist concurrently. Entering one necessarily exits the other. - Scoped invariants: A card in
GestureIdleholds no pointer coordinates or target slots. A card inGestureSettlingholds release physics (originOffset,targetOffset,velocity), not active drag thresholds. - Explicit preemption: If a new drag starts while the card is in
GestureSettling, the state machine explicitly interrupts the active spring animation, resets, and transitions cleanly intoGestureLifted.
3. Algebraic State Design in Dart 3
Representing states as bare labels (idle, reordering, relocating) still leaves a common gap: where do state-specific values live? If targetSlotIndex, hoveredQueuePosition, passedThreshold, and velocity sit as nullable fields on a single shared controller, every consumer still has to remember which fields are non-null in which phase.
Dart 3 sealed classes solve this directly through sum types (algebraic data types). Each state becomes an immutable concrete class carrying strictly the data meaningful in that phase.
In FNQ's gesture_state_machine.dart, the entire state space is modeled as a @immutable sealed class GestureState hierarchy:
The remaining three variants capture their own domain constraints without polluting the other states:
(Equality operators, hashCode, and toString() overrides are implemented on each concrete class so ValueNotifier compares states structurally.)
Engineering Trade-Offs at 120Hz
Allocating a fresh immutable GestureReordering or GestureDismissing instance on every pointer tick raises a reasonable systems question: what does that cost inside an 8.3ms frame budget?
In the Dart VM, short-lived small immutable objects allocate via bump-pointer allocation in the young-generation nursery (scavenge space), where dead instances from previous frames are reclaimed cheaply in minor collections. In exchange, the architecture gains:
- Compile-time exhaustiveness: Any
switch (state)overGestureStateis checked by the Dart analyzer. Adding a new gesture phase immediately flags every incomplete consumer at compile time. - Non-nullable phase guarantees: A consumer matching
GestureSettling(:final originOffset, :final targetOffset, :final velocity)receives guaranteed physics coordinates with zero runtime null checks. - Scoped reactive updates: Downstream overlays observe
ValueListenable<GestureState>rather than forcing full widgetsetState()rebuilds.
4. The Decoupled State Machine: Transitions, Geometry, and Scale
With the state algebra defined, the state machine itself is a plain Dart class extending Flutter's lightweight ValueNotifier<GestureState>:
Notice what is absent from GestureStateMachine: there is no BuildContext, no RenderBox, no TickerProvider, and no OverlayPortalController. Every spatial input enters as an immutable dart:ui value (Offset, Rect, Velocity).
Guarded Lifecycle Transitions
Four explicit methods govern every drag transaction:
lift(...): RecordscardRect,containerRect,pointerStart, and the active interactionbehavior(supporting separate behaviors for standard drag vs. long-press drag), moving intoGestureLifted.update(...): Evaluates incoming pointer coordinates against the active behavior and emits the appropriate active phase (GestureDismissing,GestureRelocating, orGestureReordering).settle(...): Captures release geometry and velocity intoGestureSettling.reset(): Clears cached bounds and returns the machine toconst GestureIdle().
Two invariants protect transition integrity at the entry points:
If an asynchronous pointer delta arrives after the card has already settled or reset to idle, update() ignores it deterministically rather than resurrecting a stale drag.
Unified Container Boundary Escape
Hybrid ReorderAndRelocate gestures introduce a subtle geometric requirement. In a stack containing three or four notifications, dragging a card from the top slot down to the bottom slot covers substantial vertical distance—often far outside the card's own original cardRect, yet still squarely inside the queue container.
Measuring escape against the card's individual bounds would prematurely switch to screen relocation while the user is simply trying to reorder down the stack. Instead, GestureStateMachine captures both cardRect and containerRect (resolved from the queue list's RenderBox) at lift(), and checks escape exclusively against the container perimeter inflated by the escape threshold:
Because isInside is evaluated purely from _containerRect and globalPosition inside update(), crossing out of the stack immediately switches the state machine to GestureRelocating, and dragging back inside transitions just as cleanly back to GestureReordering.
State-Driven Visual Scaling
Derived visual properties also belong to the state machine rather than being duplicated across widgets. getActiveScale(double maxScale) maps each GestureState variant to its exact target scale via an exhaustive switch:
When a directional swipe crosses its threshold (GestureDismissing(passedThreshold: true)), the scale contracts to 0.85 to signal imminent commitment; upon lift-off (GestureLifted), it subtly elevates to 1.02; upon release (GestureSettling), it returns cleanly to 1.0.
5. Connecting the Widget Tree, Plugins, and Spring Physics
Keeping GestureStateMachine pure does not eliminate the Flutter widget layer—it shrinks that layer's responsibility to measuring layout bounds, forwarding pointer events, and projecting state changes into pixels.
The boundary between Flutter and the FSM flows in one direction:
1. Host Coordination & Interruptible Snap-Back
In DraggableTransitionsState, onDragStarted checks whether a previous release spring is still active (_fsm.value is GestureSettling), stops the AnimationController, resets the machine, measures both the card's RenderBox and the queue's listRenderBox, and lifts the FSM:
During onDragUpdate, slot selection in _nearestZoneIndexWithHysteresis applies a 40-pixel gravityWell bias toward the currently active slot before forwarding targetSlotIndex and proximity progress into _fsm.update(...). When onDragEnd determines that the card neither dismissed, reordered, nor relocated, _startSnapBack enters GestureSettling and projects the 2D release velocity vector onto the 1D snap-back axis to drive SpringSimulation:
Because card translation reads _fsm.value is GestureSettling directly inside AnimatedBuilder, no separate _isSnapBackAnimating flag or setState() call is needed to start or end the spring return.
2. Thin Behavior Plugins & Target Overlays
Each interaction plugin receives a DragGestureContext exposing fsm and ValueListenable<GestureState> get gestureState. In ReorderRelocateGesturePlugin, both drag updates and visual feedback pattern-match directly against ctx.fsm.value:
In buildFeedback, state is GestureRelocating cleanly swaps the OverlayPortal content from ReorderTargets + LiftedFeedback to RelocationTargets—while every target overlay (ReorderTargets, RelocationTargets, DismissalTargets, IntentTargets) subscribes directly to ValueListenable<GestureState> (gestureState) and reads state.pointerPosition without rebuilding the underlying card.
3. Deduplicated Spring Feedback
Spring feedback widgets (LiftedFeedback, DismissFeedbackOverlay, and IntentFeedbackOverlay) attach a listener to widget.fsm and evaluate widget.fsm?.getActiveScale(...). Crucially, they cache _lastTargetScale so continuous pointer updates inside the same discrete phase do not restart the SpringSimulation on every frame:
This keeps continuous 120Hz motion smooth while allowing discrete phase transitions (GestureLifted -> GestureReordering -> GestureDismissing(passedThreshold: true)) to trigger crisp physical spring transitions.
6. Deterministic, Widget-Free Verification
The immediate engineering payoff of isolating GestureStateMachine from BuildContext and RenderBox is testability.
Every core invariant—lift geometry capture, ReorderAndRelocate container escape and re-entry, settle() / reset() lifecycles, and getActiveScale() values—can be verified in pure Dart unit tests in test/interaction/gesture_fsm_test.dart, without WidgetTester, MaterialApp, or frame pumps:
Running the isolated state-machine suite executes all 12 tests—covering spring configuration bounds, transition lifecycles, boundary escape/re-entry, and active scale outputs—in under a second, while the full FNQ suite verifies the widget overlays alongside it:
7. When an FSM Is Worth the Ceremony
Explicit state machines carry real boilerplate—defining sealed variants, constructors, equality overrides, and transition methods. Applying that machinery indiscriminately to simple UI state adds ceremony without benefit.
Use a simple heuristic before introducing a dedicated FSM in Flutter:
| Interaction Profile | Recommended Model | Rationale |
|---|---|---|
| Discrete UI states (loading, content, error), toggles, single-axis animations | enum + ValueNotifier or widget State | No cross-mode geometry or phase-scoped invariants |
| Multi-phase continuous gestures with reversible modes or interruptible physics | Sealed GestureState + ValueNotifier FSM | Eliminates impossible flag combinations, decouples transition math from RenderBox, and enables sub-second pure-Dart testing |
An explicit FSM earns its keep the moment an interaction exhibits at least two of these traits:
- Mutually exclusive continuous modes driven by a single pointer stream (such as stack reordering vs. screen-wide relocation).
- Reversible transitions inside one gesture (escaping a container boundary and drifting back inside).
- Phase-scoped invariants (data like
targetSlotIndex,hoveredQueuePosition, or releaseVelocitythat are only valid in one phase). - Preemptible physics simulations where a user can grab a widget mid-snap-back and immediately start a new transaction.
By separating what phase an interaction is in from how Flutter renders that phase, gesture code stops fighting setState() and widget lifecycles. Transitions become explicit, invalid states become unrepresentable, and complex 120Hz interactions remain deterministic enough to verify in plain unit tests.
- GitHub Repository: github.com/pccria/flutter_notification_queue
- pub.dev Package: pub.dev/packages/flutter_notification_queue
- API Documentation: pccria.github.io/flutter_notification_queue/api