Published on

Telling "Who Did It" Apart in an Event Stream: Why a Single Boolean Flag Is Doomed Against Multi-Frame Animation

Authors
  • avatar
    Name
    Jack Qin
    Twitter

A lot of interaction logic has to answer the same question: "this state change — did the user do it, or did the system do it itself?" A map's "follow mode" is a clean sample: the map automatically tracks a target as it moves, but the moment the user drags manually it should drop out of follow. The hard part isn't the following, it's the discriminationreact-native-maps's onRegionChange fires identically for a user drag and a programmatic animateCamera, and the event payload carries no flag telling you "this is the user, not me."

This post isn't about how to write the following; it's about dissecting a more general modeling error: when the discrimination signal itself is missing, developers instinctively reach for a boolean flag to "mark" the programmatic operation; and that boolean is doomed, because it makes a wrong assumption about the operation's temporal structure — it assumes "one programmatic operation = one event." Understanding why that assumption inevitably breaks in the reality of multi-frame animation is more transferable than memorizing the "use a time window" fix.


The symptom: follow drops out on its own the moment it starts

After follow mode turns on, the camera moves to the target and then immediately drops out of follow on its own. It reproduces 100% on a release build but sometimes hides on a slow dev build — that "always reproduces in the fast environment, occasionally dodges in the slow one" trait is itself a clue, and below explains why it points at frame count.

The most natural thing to write: set a boolean ref before the animation, clear it in the next onRegionChange.

// Wrong —— single-event flag, only suppresses the first frame of a multi-frame animation.
const suppressNextRef = useRef(false);

function animateToDriver(coord: Coord): void {
  suppressNextRef.current = true;     // ← only suppresses the first frame
  mapRef.current?.animateCamera({ center: coord, ... }, { duration: 300 });
}

function handleRegionChange(): void {
  if (suppressNextRef.current) {
    suppressNextRef.current = false;  // ← cleared after the first frame
    return;
  }
  onUserPan();                         // frames 2..N are treated as a user drag → bug
}

Mechanism 1: The discrimination signal simply doesn't exist

First, recognize why this problem has no "happy path." onRegionChange / onRegionChangeComplete fire isomorphically for a user gesture and a programmatic animateCamera / animateToRegion — the event object has no origin field. onPanDrag looks like it could fill the gap, but it exists on iOS and not on Android, and even on iOS it misses continuous gestures like "keep dragging after a pinch zoom."

In other words, the platform does not hand you a reliable "is it the user" signal. Since the signal is missing, you can only manufacture one yourself: proactively record some state before the programmatic trigger, and use it as a back-reference when the event arrives. The question isn't "should I record state myself," it's "what shape should I record it in."


Mechanism 2: One animation emits multiple events — the boolean's fatal assumption

The boolean flag's implicit assumption is: one animateCamera call corresponds to one onRegionChange, so "suppress the next event" is enough. This assumption is wrong.

A single animateCamera call emits multiple onRegionChange events — one per frame on iOS, batched a few at a time on Android. A 300ms animation at 60fps is a dozen-plus events. The boolean flag is cleared only on the first event, and the remaining dozen-plus frames all slip through, getting registered by onRegionChange as a user drag — so every programmatic re-center silently breaks follow.

This also explains the opening "always reproduces in fast, occasionally dodges in slow" trait: the number of events an animation emits correlates with frame rate. A release build has a high frame rate, every animation fires a long string of events, and a slipped-through frame almost certainly hits the window after the flag is cleared; a slow dev build occasionally fires only one event for an animation, which the single flag happens to cover completely, so the bug goes invisible. The better the performance, the more reliably the bug reproduces — a direct symptom of the boolean's wrong assumption about the events' temporal structure.

The root of the error: a programmatic move is not a point (a single event) but an interval (a string of events). Using a boolean that can only cover one point to cover an interval inevitably leaks.


The fix: model the programmatic move as a time window

The right abstraction matches its real temporal structure — treat the programmatic move as covering a time window. Any internal call that drives animate* pushes the window's end timestamp forward; before onRegionChange classifies an event as "user-initiated," it first checks whether we're still inside the window.

// MapScreen.tsx (or wherever you hold the MapView ref)
const programmaticMoveUntilRef = useRef<number>(0);

// The window must be longer than the longest internal animation that may fire.
// useAnimateToSite defaults to 600ms; add margin for settle frames.
const PROGRAMMATIC_MOVE_SUPPRESS_MS = 800;

/** Call this before any internal animate*() call on the MapView. */
function markProgrammaticMove(): void {
  programmaticMoveUntilRef.current = Date.now() + PROGRAMMATIC_MOVE_SUPPRESS_MS;
}

function handleRegionChange(): void {
  if (Date.now() < programmaticMoveUntilRef.current) return; // programmatic
  onUserPan?.();                                             // user gesture
}

function animateToDriver(c: Coord): void {
  markProgrammaticMove();
  mapRef.current?.animateCamera({ center: c, ... }, { duration: 300 });
}

The window model is naturally robust to multi-frame animation: all frames of one animation fall inside the same window, whether it emits 1 event or 50. It's also robust to overlapping internal moves — a later markProgrammaticMove() call simply extends the window's end, instead of fighting each other the way two boolean flags would.

Side-effect-triggered animations must bump the window too

Some camera moves aren't triggered by the user touching the map (switching sites, a deep-link route change, programmatic zoom from a sheet). These animations also fire a string of onRegionChange events and likewise need to bump the window, otherwise the frames they produce get misjudged as a user drag:

// Switching sites re-aims the camera via a downstream hook (useAnimateToSite).
// Bump the window so the frames it produces aren't treated as a user drag and silently break follow.
useEffect(() => {
  if (siteId) markProgrammaticMove()
}, [siteId])
TriggerRequired call before the animation
mapRef.animateCamera({ ... }) for a follow re-centermarkProgrammaticMove()
mapRef.animateToRegion(...) for zoom-in/out buttonsmarkProgrammaticMove()
Site-switch animation (useAnimateToSite and the like)markProgrammaticMove()
User drag / pinch / two-finger rotateNone — onRegionChange must fire onUserPan

Window length: an explicit knob priced to the slowest device

The time window turns a hidden hazard from "code structure" into "a numeric parameter" — both an advantage and its boundary. The window must be at least as long as the longest internal animation a screen can fire. At the time of writing, useAnimateToSite defaults to a 600ms animation, and 800ms gives a 200ms settle buffer.

Its failure mode is explicit and reasoned-about:

ScenarioExpected behavior
Following, user single-finger dragAfter the suppress window expires, onRegionChange fires → onUserPan() → drop out of follow
Following, user pinch zoomSame as above — drop out
App calls animateCamera for a follow tickAll frames within the 800ms window classified as programmatic → follow holds
Site change triggers useAnimateToSite (while following)Programmatic — follow holds (switching sites shouldn't lose follow)
Animation exceeds 800ms on a slow deviceLast few frames treated as a user gesture → follow drops out prematurely. Mitigation: price the window to the slowest real device, or bump per-frame if you can hook the animation callback

Note the last row — undersizing the window silently breaks follow, and adding a longer animation means you must enlarge the window accordingly. This is the time-window approach's honest boundary: it swaps "an inevitably failing boolean" for "a threshold that must be calibrated to real devices" — higher determinism, but it requires you to know your slowest target device.


Testing and the transferable layer

The test is pure logic — extract the decision into a function whose inputs are the current time + the last programmatic timestamp + an isProgrammatic boolean, which Vitest can cover without React (this project's renderHook is unavailable):

CaseAssertion
now < programmaticUntildecideOnPan(state, true).next === state — unchanged, follow holds
now >= programmaticUntil and isFollowing.next.isFollowing === false — drop out of follow
now >= programmaticUntil and not isFollowing.next === state — no-op

The multi-frame trap also needs one manual reproduction (do it when introducing a new map feature): on a release build, turn on follow → at zoom > 14, tap the FAB (so the animation is a pure camera move, not zoom-then-center) → confirm that after settling the FAB still reads "following." If it flips to "not following" on its own, the window is too short or markProgrammaticMove() was missed.

Set aside the map's specific API, and the truly transferable lesson is: when an operation occupies an interval in time but you mark it with a flag that covers only one instant, the rest of the events in the interval inevitably leak. Any scenario where "a programmatic trigger emits a string of events rather than one" — animations, observer callbacks fired by batched DOM mutations, multiple inputs within a debounce window — shares this trap. When modeling, first ask: the operation I want to suppress/classify — is it a point or an interval on the timeline? If it's an interval, don't use a boolean, use a window; and price the window length to your slowest real environment.