[ SKIP TO MAIN CONTENT ↵ ]
← ALL WRITINGSNOV 11, 202611 min

Taming Complex Flutter Gestures with Finite State Machines

Why declarative UI flags break down under continuous multi-phase gestures, and how a pure-Dart finite state machine keeps 120Hz interactions deterministic and testable.

PUBLISHED:NOV 11, 2026
READ TIME:11 min
TOPICS:
#Flutter#Dart#State Machines#Architecture#Gestures
NOV 11, 2026
PUBLISHED

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:

  1. 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.
  2. 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.
  3. 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).
  4. Boundary re-entry: If the user changes their mind and drags back inside the queue perimeter before releasing, transition smoothly back into stack reordering.
  5. Release & settle: Commit a slot reorder, relocate the notification to another screen corner, trigger a directional intent (Dismiss, Snooze, Pin, or CustomAction), or snap the card back to rest via a velocity-projected SpringSimulation—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:

// DART
// Anti-pattern: implicit state encoded across independent booleans
class _DraggableCardState extends State<DraggableCard> {
  bool _isDragging = false;
  bool _isReordering = false;
  bool _isRelocating = false;
  bool _passedThreshold = false;
  bool _isSnapBackAnimating = false;

  Offset? _dragOffset;
  int? _hoveredSlotIndex;
  QueuePosition? _hoveredQueuePosition;
}

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:

// TEXT
[GestureIdle]
      │
      │ lift(pointerStart, cardRect, containerRect)
      ▼
[GestureLifted]
      │
      ├── Dismiss / Snooze / Pin / CustomAction ──> [GestureDismissing]
      │
      ├── Reorder ────────────────────────────────> [GestureReordering]
      │                                                   ▲     │
      ├── ReorderAndRelocate ─────────────────────────────┤     │
      │     (inside containerRect.inflate)                │     │ pointer escapes
      │                                           re-enters     │ container bounds
      └── Relocate ─────────────────────────────────────┐ │     ▼
                                                        └─┴──> [GestureRelocating]
      │
      │ settle(originOffset, targetOffset, velocity)
      ▼
[GestureSettling] ──── reset() (spring ends or interrupted) ──> [GestureIdle]

Notice the structural difference from boolean flags:

  1. Mutually exclusive modes: GestureReordering and GestureRelocating cannot exist concurrently. Entering one necessarily exits the other.
  2. Scoped invariants: A card in GestureIdle holds no pointer coordinates or target slots. A card in GestureSettling holds release physics (originOffset, targetOffset, velocity), not active drag thresholds.
  3. 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 into GestureLifted.

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:

// DART
/// The reactive states of a notification drag-gesture transaction.
@immutable
sealed class GestureState {
  const GestureState();

  /// The current tracking global offset coordinates of the pointer, if active.
  Offset? get pointerPosition => switch (this) {
        GestureIdle() => null,
        GestureLifted(:final pointerPosition) => pointerPosition,
        GestureReordering(:final pointerPosition) => pointerPosition,
        GestureRelocating(:final pointerPosition) => pointerPosition,
        GestureDismissing(:final pointerPosition) => pointerPosition,
        GestureSettling() => null,
      };
}

/// The notification is stationary, resting in its queue stack.
final class GestureIdle extends GestureState {
  const GestureIdle();
}

/// The gesture is initiated (lifted), capturing bounds and pointer coordinates.
final class GestureLifted extends GestureState {
  const GestureLifted({
    required this.pointerPosition,
    required this.cardRect,
    required this.containerRect,
  });

  @override
  final Offset pointerPosition;
  final Rect cardRect;
  final Rect containerRect;
}

/// Actively reordering the notification card within its home queue stack slots.
final class GestureReordering extends GestureState {
  const GestureReordering({
    required this.pointerPosition,
    required this.cardRect,
    required this.containerRect,
    required this.targetSlotIndex,
    required this.progress,
  });

  @override
  final Offset pointerPosition;
  final Rect cardRect;
  final Rect containerRect;
  final int? targetSlotIndex;
  final double progress;
}

The remaining three variants capture their own domain constraints without polluting the other states:

// DART
/// Actively relocating the notification card to alternative screen positions.
final class GestureRelocating extends GestureState {
  const GestureRelocating({
    required this.pointerPosition,
    required this.cardRect,
    required this.containerRect,
    required this.hoveredQueuePosition,
  });

  @override
  final Offset pointerPosition;
  final Rect cardRect;
  final Rect containerRect;
  final QueuePosition? hoveredQueuePosition;
}

/// Actively dismissing or performing directional swipe action on the card.
final class GestureDismissing extends GestureState {
  const GestureDismissing({
    required this.pointerPosition,
    required this.cardRect,
    required this.passedThreshold,
  });

  @override
  final Offset pointerPosition;
  final Rect cardRect;
  final bool passedThreshold;
}

/// The pointer has been released. The card is settling into its final
/// position via physics-based snap-back animations or transitioning to a
/// new queue.
final class GestureSettling extends GestureState {
  const GestureSettling({
    required this.originOffset,
    required this.targetOffset,
    required this.velocity,
  });

  final Offset originOffset;
  final Offset targetOffset;
  final Velocity velocity;
}

(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) over GestureState is 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 widget setState() 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>:

// DART
class GestureStateMachine extends ValueNotifier<GestureState> {
  GestureStateMachine({
    required this.initialBehavior,
    required this.initialPosition,
    this.escapeThreshold = 80.0,
  })  : _activeBehavior = initialBehavior,
        super(const GestureIdle());

  final QueueNotificationBehavior initialBehavior;
  QueueNotificationBehavior _activeBehavior;
  QueueNotificationBehavior get activeBehavior => _activeBehavior;

  final QueuePosition initialPosition;
  final double escapeThreshold;

  Offset? _pointerPosition;
  Offset? get pointerPosition => _pointerPosition;

  Rect _cardRect = Rect.zero;
  Rect get cardRect => _cardRect;

  Rect _containerRect = Rect.zero;
  Rect get containerRect => _containerRect;

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:

  1. lift(...): Records cardRect, containerRect, pointerStart, and the active interaction behavior (supporting separate behaviors for standard drag vs. long-press drag), moving into GestureLifted.
  2. update(...): Evaluates incoming pointer coordinates against the active behavior and emits the appropriate active phase (GestureDismissing, GestureRelocating, or GestureReordering).
  3. settle(...): Captures release geometry and velocity into GestureSettling.
  4. reset(): Clears cached bounds and returns the machine to const GestureIdle().

Two invariants protect transition integrity at the entry points:

// DART
void update({
  required final Offset delta,
  required final Offset globalPosition,
  final int? targetSlotIndex,
  final double? progress,
  final QueuePosition? hoveredQueuePosition,
  final bool? passedThreshold,
}) {
  if (value is GestureIdle || value is GestureSettling) {
    return;
  }

  _pointerPosition = globalPosition;
  // ...
}

void settle({
  final Offset originOffset = Offset.zero,
  final Offset targetOffset = Offset.zero,
  final Velocity velocity = Velocity.zero,
}) {
  if (value is GestureIdle) {
    return;
  }
  value = GestureSettling(
    originOffset: originOffset,
    targetOffset: targetOffset,
    velocity: velocity,
  );
}

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:

// DART
} else if (behavior is ReorderAndRelocate) {
  final activeEscape = identical(behavior, initialBehavior)
      ? escapeThreshold
      : behavior.escapeThresholdInPixels;
  // Escape boundary logic: check if pointer has escaped container boundary
  final isInside =
      _containerRect.inflate(activeEscape).contains(globalPosition);

  if (isInside) {
    if (value is! GestureReordering) {
      _logger.debug('FSM transitioned to Reordering (inside container)');
    }
    value = GestureReordering(
      pointerPosition: globalPosition,
      cardRect: _cardRect,
      containerRect: _containerRect,
      targetSlotIndex: targetSlotIndex,
      progress: progress ?? 0.0,
    );
  } else {
    if (value is! GestureRelocating) {
      _logger.debug('FSM transitioned to Relocating (escaped container)');
    }
    value = GestureRelocating(
      pointerPosition: globalPosition,
      cardRect: _cardRect,
      containerRect: _containerRect,
      hoveredQueuePosition: hoveredQueuePosition,
    );
  }
}

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:

// DART
double getActiveScale(final double maxScale) => switch (value) {
      GestureIdle() => 1.0,
      GestureLifted() => 1.02,
      GestureReordering() => maxScale,
      GestureRelocating() => maxScale,
      GestureDismissing(:final passedThreshold) =>
        passedThreshold ? 0.85 : maxScale,
      GestureSettling() => 1.0,
    };

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:

// TEXT
  Pointer / Draggable Events (Widget Layer)
        │
        ▼
  DraggableTransitionsState
   ├── Measures RenderBox -> cardRect, containerRect
   ├── Computes slot hysteresis (40px gravity well)
   └── Calls _fsm.lift() / _fsm.update() / _fsm.settle() / _fsm.reset()
        │
        ▼
  GestureStateMachine (ValueNotifier<GestureState>)
        │
        ├──▶ ValueListenableBuilder<GestureState> ──▶ Plugins (ReorderRelocate, Dismiss, ...)
        ├──▶ ValueListenable<GestureState>        ──▶ Target Overlays (ReorderTargets, RelocationTargets, ...)
        └──▶ fsm.addListener() + getActiveScale() ──▶ Spring Feedback (LiftedFeedback, DismissFeedbackOverlay)

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:

// DART
void onDragStarted() {
  if (_fsm.value is GestureSettling) {
    _snapBackController.stop();
    _fsm.reset();
  }

  originalIndex = stateIndexOfThisItem();

  final renderBox = context.findRenderObject() as RenderBox?;
  final cardRect = renderBox != null
      ? (renderBox.localToGlobal(Offset.zero) & renderBox.size)
      : Rect.zero;

  final position = widget.notification.queue.position;
  final queueKey = coordinator.getWidgetKey(position);
  final queueState = queueKey.currentState;
  final listBox = queueState?.listRenderBox;
  final containerRect = listBox != null
      ? (listBox.localToGlobal(Offset.zero) & listBox.size)
      : cardRect;

  final pointerStart = _dragStartData?.pointerPosition ?? Offset.zero;

  _fsm.lift(
    pointerStart: pointerStart,
    cardRect: cardRect,
    containerRect: containerRect,
    behavior: behavior,
  );
  // ...
  plugin.onDragStart(this);
}

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:

// DART
Widget _buildCardChild() => AnimatedBuilder(
      animation: _snapBackController,
      builder: (final context, final child) {
        final offset =
            _fsm.value is GestureSettling && _snapBackAnimation != null
                ? _snapBackAnimation!.value
                : Offset.zero;

        return Transform.translate(
          offset: offset,
          child: widget.notification,
        );
      },
    );

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:

// DART
@override
void onDragUpdate(
  final DragGestureContext ctx,
  final DragUpdateDetails details,
) {
  final position = ctx.notification.queue.position;
  final queueKey =
      ctx.notification.effectiveCoordinator.getWidgetKey(position);
  final queueState = queueKey.currentState;

  if (queueState != null) {
    // Escape condition is evaluated exclusively inside the FSM
    switch (ctx.fsm.value) {
      case GestureRelocating():
        queueState.clearDragTarget();
      case GestureReordering(:final targetSlotIndex?):
        queueState.updateDragTarget(targetSlotIndex);
      default:
        break;
    }
  }
}

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:

// DART
void _animateToTarget() {
  final bool engaged = widget.nearestProgress > 0.3;
  final double baseScale = widget.passedThreshold
      ? 0.96
      : engaged
          ? 1.06
          : 1.04;
  final double targetScale =
      widget.fsm?.getActiveScale(baseScale) ?? baseScale;
  if (_lastTargetScale == targetScale) {
    return;
  }
  _lastTargetScale = targetScale;

  final simulation = SpringSimulation(
    widget.springPhysics.toSpringDescription(),
    _controller.value,
    targetScale,
    0.0,
  );

  _controller.animateWith(simulation);
}

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:

// DART
test('ReorderAndRelocate boundary escape logic', () {
  final fsm = GestureStateMachine(
    initialBehavior: ReorderAndRelocate<OnDrag>.to(
      positions: {QueuePosition.topLeft},
      escapeThresholdInPixels: 50,
    ),
    initialPosition: QueuePosition.topRight,
    escapeThreshold: 50.0,
  );

  // Card is at Rect.fromLTWH(100, 100, 200, 50)
  // Container is at Rect.fromLTWH(100, 100, 200, 200)
  // Expanded boundary (inflated by 50) is Rect.fromLTWH(50, 50, 300, 300)
  fsm
    ..lift(
      pointerStart: const Offset(200.0, 125.0),
      cardRect: const Rect.fromLTWH(100.0, 100.0, 200.0, 50.0),
      containerRect: const Rect.fromLTWH(100.0, 100.0, 200.0, 200.0),
    )
    // Update inside boundary: stays in reordering
    ..update(
      delta: const Offset(10.0, 10.0),
      globalPosition: const Offset(210.0, 135.0),
    );
  expect(fsm.value, isA<GestureReordering>());

  // Update outside boundary (e.g. globalPosition X = 360, Y = 125 > 350)
  fsm.update(
    delta: const Offset(150.0, 0.0),
    globalPosition: const Offset(360.0, 125.0),
  );
  expect(fsm.value, isA<GestureRelocating>());

  // Drift back inside boundary (X = 200, Y = 125)
  fsm.update(
    delta: const Offset(-160.0, 0.0),
    globalPosition: const Offset(200.0, 125.0),
  );
  expect(fsm.value, isA<GestureReordering>());

  fsm.dispose();
});

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:

PURE-DART FSM VERIFICATION[/home/ono/Projects/flutter_notification_queue]
$flutter test test/interaction/gesture_fsm_test.dart
// TEXT
00:00 +0: SpringPhysicsConfiguration Tests Default and Preset Spring Physics Configuration values
...
00:00 +9: GestureStateMachine Lifecycle & Transitions Tests ReorderAndRelocate boundary escape logic
[DEBUG] [fnq.notification.fsm] Gesture lifted at Offset(200.0, 125.0), card: Rect.fromLTRB(100.0, 100.0, 300.0, 150.0), container: Rect.fromLTRB(100.0, 100.0, 300.0, 300.0)
[DEBUG] [fnq.notification.fsm] FSM transitioned to Relocating (escaped container)
[DEBUG] [fnq.notification.fsm] FSM transitioned to Reordering (inside container)
00:00 +10: GestureStateMachine Lifecycle & Transitions Tests Settle and Reset transitions
00:00 +11: GestureStateMachine Lifecycle & Transitions Tests Active scale transitions
00:00 +12: All tests passed!

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 ProfileRecommended ModelRationale
Discrete UI states (loading, content, error), toggles, single-axis animationsenum + ValueNotifier or widget StateNo cross-mode geometry or phase-scoped invariants
Multi-phase continuous gestures with reversible modes or interruptible physicsSealed GestureState + ValueNotifier FSMEliminates 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 release Velocity that 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.

[ PUBLICATION DETAILS ]MIT LICENSE · OPEN SOURCE SYSTEMS RESEARCH

Published in the pccria.dev systems journal. Formatted with typographic restraint, verifiable source logic, and architectural precision.

← ALL WRITINGSGITHUB REPOSITORY ↗
Vim navigation disabled. Press Alt+V or click to enable.Vim off (tap to enable)
Top