powered by
etapx

0%

(
July 28, 2026
)

One Strip, Three Jobs: Composing a Drag Gesture With Taps

How Influxx Mobile's chat context strip is a keyboard-dismiss drag target, a Tools toggle, and a PR button at once, and the exact thresholds that keep taps from being eaten by the drag.
One Strip, Three Jobs: Composing a Drag Gesture With Taps
One Strip, Three Jobs: Composing a Drag Gesture With Taps
How Influxx Mobile's chat context strip is a keyboard-dismiss drag target, a Tools toggle, and a PR button at once, and the exact thresholds that keep taps from being eaten by the drag.

The strip sitting above the composer in Influxx Mobile looks like a simple label — a workspace name, a branch, a diff count. It's actually three separate interactive surfaces stacked on one row: a drag target that dismisses the keyboard, a disclosure toggle for the Tools row, and a tap target that opens the changed-files sheet. Getting a parent drag gesture and a row of buttons to coexist on the same pixels without one swallowing the other is a specific, solvable React Native problem, and the fix shipped in Influxx Mobile 1.2 is small enough to read in full.

One Row, Three Jobs

MobileNativeChatWorktreeChrome.tsx renders a single row: a Tools disclosure chevron, an identity pill with the worktree name and branch, a diff pill showing added/removed line counts (or a file count), and a Create PR button. MobileNativeChatChromeStrip.tsx decides which chrome to render — the full worktree strip on iOS when a worktree and a PR handler are present, or a bare Tools toggle otherwise — but either way, the same strip sits directly above the message composer, in the exact spot where a user's thumb naturally lands after typing.

That placement is also why the strip got a fourth job it doesn't advertise in its own markup: it's the drag target for dismissing the keyboard. A user mid-conversation with an agent doesn't want to hunt for a "done" button — they want to swipe down on the area just above the keyboard and have it go away, the same way a bottom sheet collapses when you pull it down. So the strip had to become a pan-gesture surface while still hosting four independently tappable elements.

Why "Just Wrap It in a Pressable" Doesn't Work

The naive version of drag-to-dismiss is a PanResponder or gesture-handler Pan wrapped around the whole row. The problem shows up the moment you also want a button inside that row: a plain Pan gesture and a plain React Native Pressable inside it are validated by two different systems. React Native's built-in responder system doesn't know anything about react-native-gesture-handler's internal gesture arbitration, so a parent Gesture.Pan() can claim the touch before a child's onPress ever fires — every tap on Create PR turns into a tiny, failed drag instead of a button press.

Influxx's fix has two parts, and both are necessary — one without the other still breaks.

Part One: Offset Thresholds on the Pan Gesture

The gesture itself is defined once, in MobileNativeChatView.tsx, and passed to a GestureDetector that wraps the chrome strip:

Gesture.Pan()
  .runOnJS(true)
  .activeOffsetY(10)
  .failOffsetY(-12)
  .onEnd((e) => {
    if (e.translationY > 18 || e.velocityY > 350) {
      Keyboard.dismiss()
    }
  })

activeOffsetY(10) tells the recognizer not to activate until the touch has moved 10 logical pixels downward — a tap, which moves close to zero pixels, never crosses that line. failOffsetY(-12) does the opposite job: if the touch moves 12 pixels upward instead, the gesture gives up immediately rather than fighting a scroll or a different swipe direction. Only when the drag actually resolves does onEnd check two independent conditions — a total downward travel past 18 pixels, or a downward velocity past 350 — and call Keyboard.dismiss() if either is true. A fast flick that hasn't traveled far yet still counts; a slow, deliberate drag that has traveled far enough also counts.

None of that, by itself, protects a button. Offset thresholds change when the parent gesture is willing to activate — they don't change how the children are recognized. A determined slow drag that starts exactly on the Create PR button and creeps past 10 pixels before lifting would still be ambiguous without the second half of the fix.

Part Two: Gesture-Handler Pressables, Not Plain Ones

The second half is a one-line import swap that both chrome files call out explicitly in comments. MobileNativeChatWorktreeChrome.tsx states the reasoning directly above the import:

// Why: this strip is the drag target for keyboard dismiss, so its buttons must
// be gesture-handler pressables that compose with the parent Pan gesture.
import { Pressable } from 'react-native-gesture-handler'

Every button on the strip — the Tools toggle, the identity pill, the diff pill, and the Create PR button — uses this Pressable, not the one from core react-native. MobileNativeChatChromeStrip.tsx's bare Tools toggle does the same. Because react-native-gesture-handler's Pressable is itself a gesture recognizer participating in the library's own gesture arbitration system, it sits in the same negotiation as the parent Gesture.Pan() instead of being invisible to it. A short tap resolves as a tap on the button; a real downward drag resolves as the pan. The two systems are actually talking to each other, instead of one blindly intercepting the other's touches.

"People reach for offset thresholds first because they're the obvious knob, and then get confused when a button three pixels wide still eats every other tap. The threshold controls when the parent gesture wakes up. It says nothing about whether the child even gets a vote. You need both halves — the parent has to give the child room to resolve first, and the child has to be a citizen of the same gesture system to use that room."

— Tomasz Wieczorek, Mobile Platform Engineer, Influxx

Why It's a Row, Not a Header and a Toolbar

It would have been simpler to split this into a fixed header with a Tools toggle and a separate scrollable toolbar for the worktree info. Influxx didn't, because the whole point of the strip is that it reads as one continuous piece of context directly above where the user is typing — Cursor and Claude's own review chrome puts the same information in one line for the same reason. Splitting it would mean deciding which half is the drag target and which isn't, and users don't reason about UI in those terms; they just swipe down wherever their thumb already is.

Keeping it one row also keeps the gesture composition problem in exactly one place. There's a single GestureDetector, wrapping a single View, containing every button that needs to keep working. If the strip grows a fifth job later, it inherits the same two-part contract for free — gesture-handler Pressable, no extra wiring — rather than needing its own bespoke gesture negotiation.

What Happens When the Strip Has No Worktree

Not every chat has a worktree attached. When MobileNativeChatChromeStrip has no worktreeId or no onCreatePr handler — or isn't running on iOS — it falls back to a minimal row containing only the Tools disclosure toggle, still built with the gesture-handler Pressable. The keyboard-dismiss gesture in the parent view doesn't change shape based on which chrome variant is mounted; it wraps whichever View the strip renders into, so the drag-to-dismiss behavior is consistent whether the user is looking at the full worktree chrome or the bare fallback.

"I didn't consciously notice this was solved until I tried it on an older build during a demo and the Create PR button just... didn't respond half the time. Turned out I was dragging a few pixels without meaning to. On the current build it never happens — tap is tap, swipe is swipe, and I stopped thinking about it entirely, which is honestly the best outcome for a gesture."

— Renee Okafor, iOS engineer at a fintech startup, Influxx user

The General Lesson

The pattern here generalizes past this one strip. Any time a parent drag gesture and interactive children share the same screen real estate in React Native, the fix is the same two-part contract: give the parent gesture explicit activation thresholds so short, low-travel touches don't trigger it, and make sure the children are recognized by the same gesture system the parent uses, not a disconnected one. Skipping either half produces a UI that looks correct in a static screenshot and breaks the instant someone's thumb doesn't move in a perfectly straight line.

"Small gesture bugs are disproportionately expensive to user trust. A button that fails to respond one time in ten doesn't read as 'rare edge case' to the person using it — it reads as 'this app is flaky,' and that impression sticks around long after the bug is fixed. The fix here cost us a threshold tune and an import swap. The alternative was months of 'sometimes the PR button doesn't work' tickets that are miserable to reproduce."

— Daniel Feldman, Head of Mobile, ETAPX

Frequently Asked Questions

Does swiping down anywhere on the screen dismiss the keyboard, or just on the strip?

Just the strip. The Gesture.Pan() is attached via a GestureDetector wrapping specifically the chrome-strip's container view, not the whole screen, so the drag-to-dismiss behavior is scoped to that row.

Why use react-native-gesture-handler's Pressable instead of adding onStartShouldSetResponderCapture logic to the core one?

Because the parent drag is itself a gesture-handler gesture, and gesture-handler's own arbitration only works between gesture-handler-native recognizers. Reimplementing that negotiation against React Native's separate responder system would mean maintaining a second, parallel conflict-resolution path for no benefit.

What happens if a user drags slowly and lifts their finger before it travels 18 pixels?

The gesture can still be active once it crosses the 10-pixel activeOffsetY threshold, but onEnd only calls Keyboard.dismiss() if translation exceeds 18 pixels or velocity exceeds 350 — a short, slow drag that doesn't meet either condition simply ends with no dismiss.

Could a fast, short flick dismiss the keyboard even if it barely moved?

Yes — that's what the velocity check is for. The onEnd condition is translation past 18 pixels or velocity past 350, so a quick flick that hasn't traveled far can still trigger a dismiss if it's fast enough.

Does this same gesture composition apply to the Tools toggle when there's no worktree?

Yes. The fallback chrome in MobileNativeChatChromeStrip.tsx uses the same gesture-handler Pressable for its Tools toggle, so it composes with the parent pan gesture the same way the full worktree chrome's buttons do.

Is there a risk of the pan gesture firing during normal scrolling of the chat transcript?

The pan gesture is scoped to the chrome strip's own view, not the transcript, so scrolling the message list is a separate gesture surface entirely and doesn't interact with this one.

Why not just use a simpler swipe-down-to-dismiss library instead of hand-rolling the gesture?

Because the strip already needed to be interactive on its own — Tools disclosure, the changed-files pill, Create PR — so a drop-in swipe library would still have needed the same gesture-composition work to avoid stealing taps from those buttons. Building it directly on gesture-handler kept the two concerns in one file instead of adding a dependency that would need the same fix layered on top anyway.

None of this is exotic gesture-handler wizardry — it's two well-documented API calls used deliberately, with the reasoning written down in the code as a comment rather than left for the next engineer to rediscover the hard way. That's the actual craft in it: not a clever trick, but knowing exactly which two pieces have to agree with each other before a drag and a tap can share the same few pixels.