Touches and Gestures

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Core Components and React Fundamentals first introduced Pressable and its onPress handler on Places's PlaceCard. This page covers touch and gesture handling in full: Pressable’s complete API and the legacy Touchables it replaces, the low-level gesture responder system underneath all of it, `PanResponder, and React Native Gesture Handler — the higher-level, UI-thread-driven library most real apps reach for once a gesture is more than a tap. The draggable map pin below is deliberately illustrative rather than exhaustive: Animations (later in this section) covers Reanimated’s shared values and worklets in full, and is what a production drag gesture would actually pair Gesture Handler with.

Pressable, Button, and the Legacy Touchables

Pressable

Pressable is React Native’s modern, composable primitive for anything that responds to touch. Beyond the onPress handler already introduced, four features cover almost everything a tap target needs:

  • A style function. style accepts either a plain style object/array (as every other Core Component’s style does) or a function of the current press state, called on every press-state change so the pressed visual feedback stays perfectly in sync with the underlying touch rather than being approximated with a separate onPressIn/onPressOut pair of setState calls:

    <Pressable
      style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
      onPress={handlePress}
    >
      <Text style={styles.buttonLabel}>Save place</Text>
    </Pressable>

    The same function form works for children, letting the rendered content itself react to pressed (an icon swapping color, for example) without any local state at all.

  • hitSlop. Expands the touchable area beyond the element’s visual bounds — either a single number applied to all four edges or a { top, left, bottom, right } object — without changing how large the element actually renders. This is how a small icon-sized favourite toggle (visually 24 density-independent points, as styling.adoc renders it) still meets the platform’s recommended minimum touch-target size without inflating its own layout.

  • android_ripple. An Android-only, { color, borderless, radius } config that plays Android’s native Material ripple feedback on press; ignored outright on iOS, where `Pressable’s own press-state-driven styling (the style function above) is the cross-platform equivalent instead of a second, iOS-specific effect.

  • disabled. Suppresses every callback (onPress, onPressIn, onPressOut, onLongPress) and stops the style function from ever reporting pressed: true, without unmounting or hiding the element — exactly the Disabled state below.

State diagram showing Pressable’s press state transitions: Idle to Pressed on press-in (onPressIn)

Button

Button is the simplest, least customizable option: a single native-looking button with a required title string, an onPress handler, and a color prop — no style prop at all, and no way to change its shape, padding, or typography beyond that one color:

import { Button } from 'react-native';

<Button title="Save place" onPress={handleSave} color={theme.colors.primary} />

It renders each platform’s own native button chrome (a filled Material button on Android, plain tinted text on iOS), which makes it the right choice only for a genuinely native-chrome control that needs no visual customization at all; every other button in Places — anything needing the theme’s own shape, spacing, or typography — is a styled Pressable instead.

The Legacy Touchables

Before Pressable, React Native shipped a family of Touchable* components, still present and still working, but considered legacy:

Component Feedback style

TouchableOpacity

Fades its child’s opacity while pressed — the most commonly reached-for legacy Touchable.

TouchableHighlight

Overlays a solid underlayColor behind its (single) child while pressed; requires exactly one child, unlike the others.

TouchableWithoutFeedback

No visual feedback at all — only the press callbacks; commonly wrapped around a child that already provides its own feedback.

TouchableNativeFeedback

Android-only: plays the platform’s native ripple, with no iOS equivalent at all — the exact gap android_ripple above closes.

Each one bakes in exactly one feedback style, which meant picking the right Touchable per platform and per visual effect — TouchableOpacity here, TouchableNativeFeedback there behind a Platform.OS check, and a plain View wrapped in TouchableWithoutFeedback wherever neither fit. Pressable supersedes the whole family with one unified, cross-platform API: the style function expresses whatever feedback a design calls for (opacity, highlight, scale, or nothing) directly, android_ripple opts into native ripple on the one platform that has it, and there is no longer a "which Touchable" decision to make. New code in Places uses Pressable exclusively; the legacy Touchables are documented here only because existing/third-party code still uses them.

The Gesture Responder System and Higher-Level Gesture APIs

The Gesture Responder System Lifecycle

Underneath Pressable (and every other touch-handling component) sits React Native’s gesture responder system — the low-level mechanism that decides, touch by touch, which single component on screen "owns" it. Any component can opt in with a matched pair of Set methods and act on the result with the matching Responder callbacks:

  • onStartShouldSetResponder — called on a new touch; returning true asks to become the responder for it.

  • onMoveShouldSetResponder — the same question asked again as the touch moves, for a component that only wants to claim a gesture once it looks like a drag rather than a tap.

  • onResponderGrant — fires once this component has actually been granted the responder role; the right place to record the touch’s starting position.

  • onResponderMove — fires on every subsequent move while this component holds the responder role, carrying the touch’s current position and delta.

  • onResponderRelease — fires when the touch lifts while this component still holds the responder role.

Because only one component can hold the responder role at a time, a parent that also wants the gesture (a ScrollView deciding whether a touch is a scroll rather than a tap on one of its children) can ask the current holder to give it up:

  • onResponderTerminationRequest — called on the current responder when another component (typically an ancestor) requests the role; returning false refuses to yield it.

  • onResponderTerminate — fires on the component that held the responder role once it has been taken away, either because it returned true from onResponderTerminationRequest or because the system revoked the role unconditionally (an incoming phone call, for example).

sequenceDiagram participant Child as View (child) participant System as Gesture Responder System participant Parent as ScrollView (parent) Child->>System: onStartShouldSetResponder() -> true System->>Child: onResponderGrant() Child->>System: onResponderMove() (touch moves) Parent->>System: onMoveShouldSetResponderCapture() -> true (looks like a scroll) System->>Child: onResponderTerminationRequest() Child-->>System: returns true (yields) System->>Child: onResponderTerminate() System->>Parent: onResponderGrant() Parent->>System: onResponderRelease() (touch lifts)

PanResponder

Wiring all five-plus callbacks above by hand for every draggable element is repetitive, so PanResponder wraps the whole lifecycle behind a single object built with PanResponder.create:

import { PanResponder } from 'react-native';

const panResponder = PanResponder.create({
  onMoveShouldSetPanResponder: (_event, gestureState) =>
    Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2,
  onPanResponderGrant: () => {
    /* touch started; record the starting offset */
  },
  onPanResponderMove: (_event, gestureState) => {
    /* gestureState.dx / gestureState.dy: total movement since the gesture began */
  },
  onPanResponderRelease: (_event, gestureState) => {
    /* gestureState.vx / gestureState.vy: release velocity, useful for a fling/momentum effect */
  },
});

<View {...panResponder.panHandlers} style={styles.draggable} />

gestureState (the second argument to every PanResponder callback) already accumulates total displacement and velocity, which is what makes PanResponder the natural pairing with an Animated.ValueXY — a single Animated.Value pair for x and y together, updated directly from gestureState.dx/dy on every move — shown in full in the draggable pin example below.

React Native Gesture Handler

PanResponder and the raw responder system both run their callbacks on the JS thread: every move event crosses the bridge/JSI boundary into JavaScript before anything visible happens, which is perceptibly laggy under JS-thread load (a busy list re-render, a network response being processed) — exactly the kind of latency React Native Architecture Under the Hood already describes the New Architecture narrowing but not eliminating for JS-thread-bound work. React Native Gesture Handler (from Software Mansion) is the modern alternative: it recognizes gestures natively and, paired with Reanimated (covered in full later in this section), can update the screen entirely on the UI thread, without waiting on the JS thread for each frame — much lower latency than PanResponder for exactly the gestures (drag, pinch, swipe) where that latency is most visible.

A gesture handler component declares one or more Gesture objects and attaches them to its subtree with GestureDetector:

import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const pan = Gesture.Pan()
  .onUpdate((event) => {
    /* event.translationX / event.translationY, updated on the UI thread */
  })
  .onEnd((event) => {
    /* event.velocityX / event.velocityY at release */
  });

<GestureDetector gesture={pan}>
  <View style={styles.draggable} />
</GestureDetector>

Beyond Gesture.Pan(), Gesture.Tap() and Gesture.Pinch() cover tap and pinch-to-zoom the same way, and gestures compose explicitly rather than through responder negotiation alone:

  • Gesture.Simultaneous(gestureA, gestureB) — both gestures are allowed to recognize and run at once (a pinch and a pan on a map, together).

  • Gesture.Race(gestureA, gestureB) — whichever gesture recognizes first "wins" and the other is cancelled, the same either/or outcome the raw responder system’s termination negotiation produces, expressed declaratively instead of through a pair of onResponderTerminationRequest/onResponderTerminate callbacks.

Swipeable and Cancellable Gestures

Swipeable (also from react-native-gesture-handler) is a ready-made swipe-to-reveal-actions row: swiping a row left or right slides its content aside to reveal renderLeftActions/renderRightActions, the pattern behind the swipe-to-delete Places row below.

A gesture is cancellable whenever something can interrupt it before it completes — exactly what makes Gesture.Race() above meaningful: a Gesture.Pan() raced against a ScrollView’s own scroll gesture is cancelled the moment the scroll gesture recognizes instead, the same either/or outcome `onResponderTerminationRequest negotiates at the lower level. .onFinalize() is the gesture-level equivalent of onResponderTerminate — it runs whether the gesture ended normally (.onEnd()) or was cancelled/failed partway through, which makes it the right place for cleanup that must run either way (resetting a dragged pin’s position if the drag is cancelled mid-gesture, for example).

Places: Press Feedback, Draggable Pins, and Swipe-to-Delete

(a) Press Feedback on PlaceCard

styling.adoc’s themed `PlaceCard already wraps its content in a Pressable; adding a style function is the only change needed for it to visibly dim and scale while pressed, with no extra state:

// src/features/places/components/PlaceCard.tsx (excerpt -- adds press feedback to the existing card Pressable)
<Pressable
  style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
  onPress={() => onPress(place.id)}
>
  {/* photo, name, category, favourite toggle unchanged from styling.adoc */}
</Pressable>
// src/features/places/components/PlaceCard.tsx (excerpt -- added to createStyles(theme) from styling.adoc)
cardPressed: {
  opacity: 0.85,
  transform: [{ scale: 0.98 }],
},

Because cardPressed is a plain style object layered through the same style array pattern styling.adoc already covers, it composes with every other conditional style PlaceCard applies without any extra branching in the component body itself.

(b) A Draggable Map Pin: PanResponder vs. Gesture Handler

Both versions below update a place’s latitude/longitude as its map pin is dragged, letting the same gesture double as a "correct the pin’s position" affordance. Neither actually moves a real map SDK’s marker view — that detail is specific to whichever map library Places renders through, out of scope for this page — both instead show the gesture wiring in isolation, moving a plain View standing in for the pin.

PanResponder, driving an Animated.ValueXY directly:

// src/features/places/components/DraggablePin.PanResponder.tsx
import { useMemo, useRef } from 'react';
import { Animated, PanResponder, View } from 'react-native';
import type { Place } from '../types';

interface DraggablePinProps {
  place: Place;
  onPositionChange: (latitude: number, longitude: number) => void;
  pointsPerDegree: number; // conversion factor supplied by the map view in use
}

export function DraggablePin({ place, onPositionChange, pointsPerDegree }: DraggablePinProps) {
  const pan = useRef(new Animated.ValueXY()).current;

  const panResponder = useMemo(
    () =>
      PanResponder.create({
        onStartShouldSetPanResponder: () => true,
        onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false }),
        onPanResponderRelease: (_event, gestureState) => {
          const newLatitude = (place.latitude ?? 0) - gestureState.dy / pointsPerDegree;
          const newLongitude = (place.longitude ?? 0) + gestureState.dx / pointsPerDegree;
          onPositionChange(newLatitude, newLongitude);
          pan.setValue({ x: 0, y: 0 }); // snap the driven Animated.ValueXY back once the real position updates
        },
      }),
    [pan, place.latitude, place.longitude, pointsPerDegree, onPositionChange],
  );

  return <Animated.View {...panResponder.panHandlers} style={[styles.pin, { transform: pan.getTranslateTransform() }]} />;
}

The same gesture with Gesture Handler’s GestureDetector/Gesture.Pan(), using plain component state here for illustration — a real implementation would drive this from a Reanimated shared value instead, exactly the UI-thread-only update Animations covers in full:

// src/features/places/components/DraggablePin.GestureHandler.tsx
import { useState } from 'react';
import { View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import type { Place } from '../types';

interface DraggablePinProps {
  place: Place;
  onPositionChange: (latitude: number, longitude: number) => void;
  pointsPerDegree: number;
}

export function DraggablePin({ place, onPositionChange, pointsPerDegree }: DraggablePinProps) {
  const [offset, setOffset] = useState({ x: 0, y: 0 });

  const pan = Gesture.Pan()
    .onUpdate((event) => {
      setOffset({ x: event.translationX, y: event.translationY }); // a Reanimated shared value avoids this re-render
    })
    .onEnd((event) => {
      const newLatitude = (place.latitude ?? 0) - event.translationY / pointsPerDegree;
      const newLongitude = (place.longitude ?? 0) + event.translationX / pointsPerDegree;
      onPositionChange(newLatitude, newLongitude);
      setOffset({ x: 0, y: 0 });
    })
    .onFinalize(() => {
      setOffset({ x: 0, y: 0 }); // runs on a cancelled/failed gesture too, e.g. raced against the map's own pan
    });

  return (
    <GestureDetector gesture={pan}>
      <View style={[styles.pin, { transform: [{ translateX: offset.x }, { translateY: offset.y }] }]} />
    </GestureDetector>
  );
}

The PanResponder version’s onPanResponderMove/onPanResponderRelease callbacks run on the JS thread on every move event; the Gesture Handler version’s .onUpdate()/.onEnd() run natively and, once paired with a Reanimated shared value instead of useState, update the pin’s position with no JS-thread round trip at all — the latency difference this section’s Gesture Handler introduction already covers, made concrete here on the same gesture.

(c) Swipe-to-Delete on the Places List

useDeletePlace follows the exact useMutation/PlacesApi shape Networking and Data Fetching covers in full, already used by text-input-and-forms.adoc’s `useAddPlace:

// src/features/places/hooks/useDeletePlace.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlacesApi } from '../api/PlacesApi';
import type { PlaceId } from '../types';

export function useDeletePlace() {
  const queryClient = useQueryClient();

  return useMutation<void, Error, PlaceId>({
    mutationFn: (id) => PlacesApi.deletePlace(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['places'] });
    },
  });
}
// src/features/places/components/SwipeablePlaceRow.tsx
import { Swipeable } from 'react-native-gesture-handler';
import { Pressable, Text, View } from 'react-native';
import { PlaceCard } from './PlaceCard';
import { useDeletePlace } from '../hooks/useDeletePlace';
import type { Place } from '../types';

interface SwipeablePlaceRowProps {
  place: Place;
  onPress: (placeId: string) => void;
  onToggleFavourite: (placeId: string) => void;
}

export function SwipeablePlaceRow({ place, onPress, onToggleFavourite }: SwipeablePlaceRowProps) {
  const deletePlace = useDeletePlace();

  return (
    <Swipeable
      renderRightActions={() => (
        <Pressable style={styles.deleteAction} onPress={() => deletePlace.mutate(place.id)}>
          <Text style={styles.deleteLabel}>Delete</Text>
        </Pressable>
      )}
    >
      <PlaceCard place={place} onPress={onPress} onToggleFavourite={onToggleFavourite} />
    </Swipeable>
  );
}

PlacesListScreen renders SwipeablePlaceRow in place of a bare PlaceCard as FlatList’s `renderItem (Lists and Scrolling covers FlatList itself); revealing the row’s actions is a swipe gesture Gesture Handler recognizes independently of the row’s own Pressable, and deletePlace.mutate(place.id) is the same useMutation mutate call `text-input-and-forms.adoc’s submit handler already uses, here removing a place instead of adding one.