Gestures
React Native's own gesture model runs here: the Gesture Responder System,
PanResponder, View's touch props, and Pressable. It's reimplemented in
JavaScript on top of GTK4 event controllers, so the source written against
it is ordinary react-native — the same file runs unchanged on iOS and
Android.
react-native-reanimated works here too, as react-native-gtkx/reanimated —
a reimplementation of its semantics on this one runtime, aliased onto the
package name. So does react-native-gesture-handler, as
react-native-gtkx/gesture-handler, which implements
GestureHandlerRootView, GestureDetector, Gesture.Pan(), Tap(),
LongPress() and Native() (and usePanGesture(), the spelling upstream is
migrating to), the Race/Simultaneous/Exclusive composers, and the
cross-gesture relations, over the responder system this page describes. The
recognizers that remain — Pinch, Rotation, Fling, Hover, Manual,
ForceTouch — throw by name; see Porting an app below.
See the Reference for the full per-recognizer tables.
Every measurement behind the decisions on this page lives in
docs/research/gestures.md — repo-only working notes, not published here.
Three layers, in the order to reach for them
1. Pressable — taps, long presses, hover and keyboard activation. It
isn't built on the responder system, it takes no negotiation, and it's what
almost every interaction actually needs.
<Pressable
onPress={open}
onLongPress={showMenu}
hitSlop={8}
style={({ pressed, hovered, focused }) => [
styles.row,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
focused && styles.rowFocused,
]}
>
<Text>Open</Text>
</Pressable>
hitSlop widens the target without changing layout; pressRetentionOffset
sets how far the pointer may drift after pressing and still activate on
release (RN's default rect, {top: 20, left: 20, right: 20, bottom: 30}, is
already generous). A release outside that rect cancels — dragging off a
control to change your mind works the way it does everywhere else.
2. The responder system and PanResponder — drags, pans, swipes, and
anything that needs to decide which view owns an interaction.
const pan = useRef(new Animated.ValueXY()).current
const responder = useRef(
PanResponder.create({
// Claim on press, or wait for movement — the choice matters, see
// "Claiming on press versus on move" below.
onMoveShouldSetPanResponder: (_event, gesture) => Math.abs(gesture.dx) > 8,
onPanResponderMove: (_event, gesture) => {
pan.setValue({ x: gesture.dx, y: gesture.dy })
},
onPanResponderRelease: () => {
// The continuing-drag idiom: fold where it ended into the offset so
// the next gesture's dx starts from zero instead of snapping back.
pan.extractOffset()
pan.setValue({ x: 0, y: 0 })
},
}),
).current
return (
<Animated.View
{...responder.panHandlers}
style={{ transform: pan.getTranslateTransform() }}
/>
)
Responder and touch props go on View and Animated.View; spreading
panHandlers onto anything else compiles and does nothing, which is worth
knowing because the idiomatic drag target is Animated.View.
PanResponder here is react-native's own file, vendored unmodified, running
against a reproduction of RN's touchHistory store — so dx/dy/vx/vy
and the clustered-touch maths are upstream's, not a reimplementation.
3. GTK event controllers — anything GTK does that React Native has no word for: drag-and-drop with real drag icons and content negotiation, zoom and rotate gestures, keyboard shortcut controllers. This is a Linux-only escape hatch, and the import says so:
import { Controllers, GtkDragSource } from "react-native-gtkx/gtk"
const row = (
<Pressable onPress={open}>
<Controllers>
<GtkDragSource
actions={Gdk.DragAction.MOVE}
onPrepare={prepare}
/>
</Controllers>
<Text>{task.title}</Text>
</Pressable>
)
See Window, navigation, and settings for Controllers in
full. For drag-and-drop of any shape, including a reorderable list,
react-native-gtkx/dnd (see the Reference) already wraps
this.
How the negotiation works
One interaction lock for the whole process, as in RN. On a press, the
*ShouldSetResponderCapture handlers run from the root down and the
*ShouldSetResponder handlers from the target up; the first to return
true wins. While the pointer is down, the lock can move upwards — an
ancestor that returns true from onMoveShouldSetResponder takes it,
provided the current holder doesn't refuse with
onResponderTerminationRequest. A descendant can never take it from an
ancestor.
The negotiation path stops at the layout root. A React Native tree here can be an island inside a native GTK widget tree, so native widgets above or between views simply take no part.
Claiming on press versus on move
Claiming on press (onStartShouldSetPanResponder) tells GTK the interaction
is yours before anything else can compete for it. Claiming on movement is
the more common shape, and slightly weaker: inside a scrolling list on a
touchscreen, the scroller can take the first few pixels before your
threshold is reached. Claim on press when the view is unambiguously a drag
handle.
Where this differs from React Native
Terminations mostly cannot be refused. In React Native and
react-native-web, onResponderTerminationRequest is consulted for a context
menu, an ancestor scroll, and a selection change. Here it's consulted in
exactly two situations — a transfer to an ancestor, and an enclosing
ScrollView scrolling. Everything else (a second mouse button, a native
widget or a GtkDragSource taking the sequence, text selection) reaches JS
only after GTK has already taken the interaction away, and GTK's claim
can't be given back — those arrive as onResponderTerminate with no
question asked. Window blur also terminates unconditionally, which is RN's
behavior too.
One pointer. A mouse is one fabricated touch; touches never has more
than one entry, and multi-finger gestureState is single-touch. Pinch and
rotate aren't available through the portable API — use GtkGestureZoom /
GtkGestureRotate through Controllers for them on Linux.
ScrollView arbitration is touch-only. All four gestures
GtkScrolledWindow runs internally are touch-only, so under a mouse a child
pan never competes with scrolling at all. On touch, a view that takes the
responder suspends the enclosing scroller for the rest of the interaction
(RN's setIsJSResponder). Scrolling with a wheel during a gesture isn't
suppressed — it terminates the responder instead, react-native-web's rule
for an ancestor scroll.
Hover fires from touch. react-native-web filters hover events coming
from a finger; GTK crossing events carry no device to filter on, GTK sends a
matching leave when a touch ends so no phantom hover sticks, and GTK's own
:hover behaves the same way. Filtering here would make Pressable the odd
widget out in its own window.
hitSlop stops at a clip. GTK stops picking at a clipping ancestor, so
slop can't escape a ScrollView viewport, or any view whose style says
overflow: "hidden" — the same limit RN documents on Android.
No Animated.event. Write the value directly
(pan.setValue({x: gesture.dx, y: gesture.dy})), which is what it would do
anyway.
Porting an app
Both react-native-reanimated and react-native-gesture-handler are
aliased onto reimplementations by both bundler presets, so their imports
resolve and their Pan code runs unedited. What's implemented of RNGH is
GestureHandlerRootView, GestureDetector, State, Pan, Tap,
LongPress and Native in both spellings (Gesture.Pan() and
usePanGesture(), and so on), the Race/Simultaneous/Exclusive
composers and the cross-gesture relations (simultaneousWithExternalGesture,
requireExternalGestureToFail, blocksExternalGesture — arbitrated in a
second, JS-only registry over the responder lock, because the lock has one
holder by design and simultaneity is a set), plus the components it
re-exports from react-native — ScrollView, FlatList, TextInput,
Switch, Pressable and the three Touchables.
What isn't implemented throws where it's used, naming itself, rather than silently doing nothing:
PinchandRotation— GTK feeds touchpad gestures properly, and nothing in this project's test rig can produce one, so they wait for a machine that can;Fling,Hover,Manual,ForceTouch, the legacy*GestureHandlercomponents, and the button family (RectButtonand friends — RNGH's own native button views, not RN components with a handler attached).
react-native-draggable-flatlist 4.0.3 and @gorhom/bottom-sheet 5.2.14
both run, and neither was stopped by this surface in the end: what they
needed was four react-native core exports (findNodeHandle, LogBox,
Keyboard, VirtualizedList) and Reanimated's useAnimatedScrollHandler,
all of which ship. This is verified by building both and driving them with a
real pointer, not by reading their imports — the probe app is
spike/core-exports; the Reference has the per-library detail.
What to do instead, where something is still missing:
- a drag —
PanResponderplusAnimated.ValueXY, as above. Portable, and what most RNGH usage in the wild amounts to; - drag and drop between zones, or a sortable list —
react-native-gtkx/dndmirrorsreact-native-reanimated-dnd's API on GTK's own drag-and-drop, and both presets alias that package name onto it; - swipeable rows — by hand today:
PanResponderfor the gesture, plus eitherAnimatedorreact-native-gtkx/reanimatedfor the motion. A bottom sheet no longer needs the hand-rolled version:@gorhom/bottom-sheetruns (see above), andAdwBottomSheetis the native one a Linux-first app reaches for instead.
examples/gallery's Gestures section is a working reference written
entirely in portable react-native, with no platform-layer import in it at
all.
Related
- Overview — the widget and subpath structure
ControllersandGtkDragSourcesit inside. - Window, navigation, and settings —
Controllersin full.