---
id: gestures-and-scrollview
title: Gestures and ScrollView
sidebar_position: 6
---
import AnimatedScrollViewExample from '!!raw-loader!.//\_examples/AnimatedScrollViewExample';
`ScrollView` handles touches on its own, outside of Gesture Handler. If there is a gesture attached to one of its children or to one of its parents, the `ScrollView` and the gesture compete for the same touch, and scrolling usually wins. To coordinate them, the `ScrollView` needs a [`Native`](/docs/gestures/use-native-gesture) gesture. Once it has one, it takes part in gesture [relations](/docs/composition/overview) like any other gesture.
This guide covers the most common layouts: gestures on list items, a `ScrollView` inside a view with a gesture, and Reanimated's `Animated.ScrollView`. It ends with a [photo gallery](#example-photo-gallery) that puts them together.
## Choosing a ScrollView
### ScrollView exported by Gesture Handler
`ScrollView` and `FlatList` exported by `react-native-gesture-handler` are [wrapped components](/docs/components/wrapped-components) with a [`Native`](/docs/gestures/use-native-gesture) gesture pre-applied. They accept relation properties directly:
```jsx
import { ScrollView } from 'react-native-gesture-handler';
function List({ pan, children }) {
return {children};
}
```
To refer to the `ScrollView` gesture in relations of other gestures, for example `block` on a gesture inside the list, read it from the [`onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER`](/docs/components/wrapped-components#ongestureupdate_can_cause_infinite_rerender) callback and pass it down. Store it in a ref or compare handler tags before calling `setState`, otherwise the callback triggers an infinite loop of re-renders.
Callbacks of the pre-applied gesture always run on the JS thread, and the Reanimated-specific configuration is not available.
### Wrapping ScrollView with GestureDetector
Any other scrollable component, for example `Animated.ScrollView` from Reanimated, `FlashList` or a pager, can be wrapped in a [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector) with [`useNativeGesture`](/docs/gestures/use-native-gesture). The scrollable component must be the direct child of the detector.
If the children of the `ScrollView` use [`VirtualGestureDetector`](/docs/core-components/gesture-detectors#virtualgesturedetector), wrap it with [`InterceptingGestureDetector`](/docs/core-components/gesture-detectors#interceptinggesturedetector) instead.
```jsx
import { ScrollView } from 'react-native';
import {
GestureDetector,
useNativeGesture,
} from 'react-native-gesture-handler';
function List({ children }) {
const scrollGesture = useNativeGesture({});
return (
{children}
);
}
```
The `Native` gesture does not affect scrolling. It only makes the `ScrollView` a gesture that other gestures can refer to. `scrollGesture` is available from the first render, so there is no callback to wait for.
### Which one to choose
- Use the exported `ScrollView` or `FlatList` when the relations are set on the `ScrollView` itself, for example a list that waits for a gesture around it.
- Wrap with `GestureDetector` when the scrollable component comes from another library.
Use one of the two, never both. Wrapping a component exported by Gesture Handler attaches a second `Native` gesture, which results in undefined behavior.
:::tip
When the list contains gestures or components from Gesture Handler, we recommend the `ScrollView` exported by the library. It takes part in gesture recognition out of the box, with no extra setup.
:::
On web, the `Native` gesture cannot block scrolling. For swipeable rows, set [`touchAction`](/docs/core-components/gesture-detectors#touchaction) on the detector, see [Custom swipeable components inside ScrollView (web)](/docs/guides/swipe-and-scroll).
## Gestures inside the list
A `ScrollView` with rows, where each row has its own gesture, for example a horizontal `Pan`. The row's gesture should win when it is recognized, otherwise the `ScrollView` should scroll. In other words, the `ScrollView` has to wait for the row's gesture to fail.
Pass the `ScrollView` gesture to [`block`](/docs/composition/block) on the row's gesture:
```jsx
function Row({ scrollGesture }) {
const pan = usePanGesture({
activeOffsetX: [-20, 20],
failOffsetY: [-10, 10],
// highlight-next-line
block: scrollGesture,
onUpdate: (e) => {
// ...
},
});
return (
{/* ... */}
);
}
```
`block` is a many-to-one relation: every row points to the single `ScrollView`, and the list does not have to keep track of its rows. This makes it suitable for lists with a dynamic number of items. The same relation can be set from the other side with [`requireToFail`](/docs/composition/require-to-fail) on the `Native` gesture (`useNativeGesture({ requireToFail: pan })`), which is convenient when there is a single gesture inside the list, created in the same component.
Nothing scrolls until the row's gesture activates or fails, so it has to fail quickly on the movement the list owns. This is what `failOffsetY` does in the example above.
### Pinch on list items
A two-finger `Pinch` on a list item is a different case. The `ScrollView` starts scrolling with the first finger, before the second one touches the screen, and once it is scrolling the `Pinch` cannot activate. `block` does not help here: a `Pinch` does not fail while a single finger moves, it waits for the second finger until the touch ends. A `ScrollView` waiting for it would not scroll with one finger at all.
The solution below requires [Reanimated](https://docs.swmansion.com/react-native-reanimated/), as it relies on passing a [`SharedValue`](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/glossary#shared-value) to the gesture configuration, see [Using SharedValue in gesture config](/docs/fundamentals/reanimated-interactions#using-sharedvalue-in-gesture-config).
Use [`simultaneousWith`](/docs/composition/simultaneous-with) so that the `Pinch` can activate while the scroll is in progress, and disable the `ScrollView` gesture while the `Pinch` is active. Pass a `SharedValue` as `enabled` of the `Native` gesture and set it to `false` when the `Pinch` begins. The `ScrollView` stops as soon as the `Pinch` begins, and scrolling with one finger is not affected.
```jsx
function Item({ scrollGesture, scrollEnabled }) {
const scale = useSharedValue(1);
const pinch = usePinchGesture({
// highlight-start
simultaneousWith: scrollGesture,
onBegin: () => {
scrollEnabled.value = false;
},
// highlight-end
onUpdate: (e) => {
scale.value = e.scale;
},
onDeactivate: () => {
scale.value = withSpring(1);
},
// highlight-start
onFinalize: () => {
scrollEnabled.value = true;
},
// highlight-end
});
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
{/* ... */}
);
}
function Gallery() {
const scrollEnabled = useSharedValue(true);
// highlight-next-line
const scrollGesture = useNativeGesture({ enabled: scrollEnabled });
return (
{ITEMS.map((item) => (
))}
);
}
```
The same works with the `ScrollView` exported by Gesture Handler: pass the shared value to its `enabled` prop and read the gesture for `simultaneousWith` from the [`onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER`](/docs/components/wrapped-components#ongestureupdate_can_cause_infinite_rerender) callback.
## ScrollView inside a gesture
The opposite layout: a view with a gesture attached, for example a drawer or a draggable card with a `Pan`, contains a `ScrollView`. Usually the `ScrollView` should take precedence, and the outer gesture should activate only when the scroll cannot, for example on a vertical drag over a horizontal list.
`requireToFail: scrollGesture` on the outer gesture does not work in this case. The `Native` gesture of a `ScrollView` does not fail while the finger is down. It either activates when the view takes over the drag, or ends when the touch ends. A gesture waiting for it cannot activate during the drag.
Instead, give the outer gesture activation criteria that fail quickly on the movement the list owns, and no relation. For a `Pan` over a horizontal list:
```jsx
function Card({ children }) {
const scrollGesture = useNativeGesture({});
const pan = usePanGesture({
// highlight-start
activeOffsetY: [-10, 10],
failOffsetX: [-10, 10],
// highlight-end
onUpdate: (e) => {
// ...
},
});
return (
{children}
);
}
```
A horizontal drag fails the `Pan` after a few points and the list scrolls. A vertical drag activates it, which cancels the `ScrollView` gesture, because the two are not simultaneous. The `Native` gesture on the `ScrollView` is still needed: it makes the scroll a gesture that the outer one can cancel.
For the opposite behavior, where the outer gesture wins and the `ScrollView` scrolls only when it fails, use a relation: `requireToFail` on the `Native` gesture, or `block: scrollGesture` on the outer gesture. Its activation criteria still matter, because the `ScrollView` cannot start until the gesture activates or fails.
## Reanimated's Animated.ScrollView
`Animated.ScrollView` from Reanimated is the React Native `ScrollView` with an animated `onScroll`. Everything above applies to it without changes. Wrap it in a `GestureDetector` with a `Native` gesture, keep `useAnimatedScrollHandler` on it and set relations on the surrounding gestures. Scroll handlers still run as worklets on the UI thread.
Swiping a row horizontally moves it, and the list does not scroll. Dragging vertically scrolls the list, and `onBeginDrag` and `onEndDrag` are called as usual.
## Example
A horizontal gallery of photos that can be pinched and double tapped to zoom, panned while zoomed and swiped away vertically. The same code runs in the [example app](https://github.com/software-mansion/react-native-gesture-handler/tree/main/apps/common-app/src/new_api/showcase/gallery). It combines the sections above:
- The `ScrollView` has a `Native` gesture with `enabled` driven by a shared value, which the `Pinch` turns off while it runs and the zoom state keeps off while a photo is zoomed.
- The `Pinch` and the `Pan` run `simultaneousWith` the `ScrollView` gesture. The `Pan` is always active, so pinching and panning can be switched by lifting or placing fingers without releasing; it moves the photo only while zoomed, and with one finger on an unzoomed photo the `ScrollView` scrolls.
- Swiping a photo up or down dismisses it. That `Pan` competes with the horizontal scroll through activation criteria only (`activeOffsetY`, `failOffsetX`), as in [ScrollView inside a gesture](#scrollview-inside-a-gesture).
- The transform is a matrix, as in the [Transformations](/docs/guides/transformations) guide, so the pinch zooms around its focal point and the double tap around the tapped point.
One detail is easy to get wrong: a double tap animation must not be folded into the matrix by another gesture ending in the meantime, so the example keeps an `animating` flag with an id and ignores callbacks of cancelled springs.
```tsx
import React, { useState } from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import type { NativeGesture } from 'react-native-gesture-handler';
import {
GestureDetector,
GestureHandlerRootView,
useNativeGesture,
usePanGesture,
usePinchGesture,
useSimultaneousGestures,
useTapGesture,
} from 'react-native-gesture-handler';
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const PHOTOS = [
{ color: '#e76f51', label: 'Sunset' },
{ color: '#2a9d8f', label: 'Lake' },
{ color: '#e9c46a', label: 'Dunes' },
{ color: '#264653', label: 'Night' },
{ color: '#f4a261', label: 'Canyon' },
{ color: '#8ab17d', label: 'Forest' },
];
const ZOOM = 2.5;
const WIDTH = 200;
const HEIGHT = 260;
const DISMISS_DISTANCE = 120;
function identity3() {
'worklet';
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
function multiply3(a: number[], b: number[]) {
'worklet';
return [
a[0] * b[0] + a[1] * b[3] + a[2] * b[6],
a[0] * b[1] + a[1] * b[4] + a[2] * b[7],
a[0] * b[2] + a[1] * b[5] + a[2] * b[8],
a[3] * b[0] + a[4] * b[3] + a[5] * b[6],
a[3] * b[1] + a[4] * b[4] + a[5] * b[7],
a[3] * b[2] + a[4] * b[5] + a[5] * b[8],
a[6] * b[0] + a[7] * b[3] + a[8] * b[6],
a[6] * b[1] + a[7] * b[4] + a[8] * b[7],
a[6] * b[2] + a[7] * b[5] + a[8] * b[8],
];
}
function scale3(s: number) {
'worklet';
return [s, 0, 0, 0, s, 0, 0, 0, 1];
}
function translate3(tx: number, ty: number) {
'worklet';
return [1, 0, 0, 0, 1, 0, tx, ty, 1];
}
function matrixScale(matrix: number[]) {
'worklet';
return Math.hypot(matrix[0], matrix[1]);
}
function createMatrix(
translation: { x: number; y: number },
scale: number,
origin: { x: number; y: number }
) {
'worklet';
let matrix = identity3();
if (scale !== 1) {
matrix = multiply3(matrix, translate3(origin.x, origin.y));
matrix = multiply3(matrix, scale3(scale));
matrix = multiply3(matrix, translate3(-origin.x, -origin.y));
}
if (translation.x !== 0 || translation.y !== 0) {
matrix = multiply3(matrix, translate3(translation.x, translation.y));
}
return matrix;
}
// Translation arrives in parent coordinates; the in-progress transform is
// applied before the accumulated matrix, so divide it by the matrix scale.
function applyTransformations(
translation: { x: number; y: number },
scale: number,
origin: { x: number; y: number },
matrix: number[]
) {
'worklet';
const s = matrixScale(matrix);
const transform = createMatrix(
{ x: translation.x / s, y: translation.y / s },
scale,
origin
);
return multiply3(transform, matrix);
}
type PhotoProps = {
color: string;
label: string;
scrollGesture: NativeGesture;
scrollEnabled: SharedValue;
};
function Photo({ color, label, scrollGesture, scrollEnabled }: PhotoProps) {
const transform = useSharedValue(identity3());
const translation = useSharedValue({ x: 0, y: 0 });
const scale = useSharedValue(1);
const origin = useSharedValue({ x: 0, y: 0 });
const zoomed = useSharedValue(false);
const notZoomed = useSharedValue(true);
const dismissY = useSharedValue(0);
const opacity = useSharedValue(1);
// Set while a double-tap animation runs, so that a gesture ending in the
// meantime (a real tap always jitters a little) doesn't fold mid-animation.
// Each animation gets an id, so a cancelled spring's callback can't clear
// the flag of the animation that replaced it.
const animating = useSharedValue(false);
const animationId = useSharedValue(0);
// Web only: while zoomed, the browser must not scroll the list from a
// drag on the photo. Native platforms ignore `touchAction`.
const [isZoomed, setIsZoomed] = useState(false);
const setZoomed = (value: boolean) => {
'worklet';
zoomed.value = value;
notZoomed.value = !value;
scrollEnabled.value = !value;
scheduleOnRN(setIsZoomed, value);
};
const isZoomedNow = () => {
'worklet';
return matrixScale(transform.value) * scale.value > 1.01;
};
// The matrix currently on screen, including in-progress gesture values and
// whatever a running animation has reached.
const currentMatrix = () => {
'worklet';
return applyTransformations(
translation.value,
scale.value,
origin.value,
transform.value
);
};
// Folds the on-screen state into the matrix, cancelling any animation.
// The id is bumped first: assigning `scale` cancels a running spring, whose
// callback fires synchronously and must see that it is no longer current.
const settle = () => {
'worklet';
animationId.value += 1;
animating.value = false;
transform.value = currentMatrix();
translation.value = { x: 0, y: 0 };
scale.value = 1;
origin.value = { x: 0, y: 0 };
setZoomed(matrixScale(transform.value) > 1.01);
};
const fold = () => {
'worklet';
if (animating.value) {
return;
}
settle();
};
const startAnimation = () => {
'worklet';
animating.value = true;
animationId.value += 1;
return animationId.value;
};
const reset = () => {
'worklet';
// Unfold what is on screen into a center pivot, then animate both parts
// to rest.
const id = startAnimation();
const m = currentMatrix();
transform.value = identity3();
origin.value = { x: 0, y: 0 };
scale.value = matrixScale(m);
translation.value = { x: m[6], y: m[7] };
translation.value = withSpring({ x: 0, y: 0 });
scale.value = withSpring(1, {}, () => {
if (animationId.value === id) {
animating.value = false;
}
});
setZoomed(false);
};
// Not zoomed, the ScrollView must not keep a two-finger gesture: the pinch
// runs alongside it and disables it as soon as it begins.
const pinch = usePinchGesture({
simultaneousWith: scrollGesture,
onBegin: () => {
scrollEnabled.value = false;
},
onActivate: (e) => {
if (animating.value) {
settle();
}
origin.value = {
x: -(e.focalX - WIDTH / 2),
y: -(e.focalY - HEIGHT / 2),
};
},
onUpdate: (e) => {
scale.value *= e.scaleChange;
},
onDeactivate: () => {
fold();
if (!zoomed.value) {
reset();
}
},
onFinalize: () => {
scrollEnabled.value = !zoomed.value;
},
});
// Always running, so that pinch and pan switch without lifting all fingers.
// It moves the photo only while zoomed; with one finger on an unzoomed
// photo it does nothing and the ScrollView scrolls.
const pan = usePanGesture({
simultaneousWith: scrollGesture,
averageTouches: true,
minDistance: 1,
onUpdate: (e) => {
if (!isZoomedNow()) {
return;
}
translation.value = {
x: translation.value.x + e.changeX,
y: translation.value.y + e.changeY,
};
},
onDeactivate: () => {
fold();
},
});
const doubleTap = useTapGesture({
numberOfTaps: 2,
onDeactivate: (e) => {
if (zoomed.value) {
reset();
return;
}
if (animating.value) {
settle();
}
origin.value = { x: -(e.x - WIDTH / 2), y: -(e.y - HEIGHT / 2) };
setZoomed(true);
const id = startAnimation();
// Target the absolute zoom level, whatever the matrix holds already.
scale.value = withSpring(ZOOM / matrixScale(transform.value), {}, () => {
if (animationId.value === id) {
animating.value = false;
settle();
}
});
},
});
// Vertical one-finger swipe on an unzoomed photo dismisses it. Direction
// criteria keep it away from the horizontal scroll.
const dismiss = usePanGesture({
enabled: notZoomed,
maxPointers: 1,
activeOffsetY: [-10, 10],
failOffsetX: [-10, 10],
onUpdate: (e) => {
dismissY.value = e.translationY;
},
onDeactivate: (e) => {
if (Math.abs(e.translationY) > DISMISS_DISTANCE) {
dismissY.value = withTiming(Math.sign(e.translationY) * 1000);
opacity.value = withTiming(0);
return;
}
dismissY.value = withSpring(0);
},
});
const gesture = useSimultaneousGestures(pinch, pan, doubleTap, dismiss);
const style = useAnimatedStyle(() => {
const matrix = applyTransformations(
translation.value,
scale.value,
origin.value,
transform.value
);
return {
opacity: opacity.value,
transform: [
{ translateX: matrix[6] },
{ translateY: matrix[7] + dismissY.value },
{ scale: matrixScale(matrix) },
],
};
});
return (
{label}
);
}
export default function App() {
const scrollEnabled = useSharedValue(true);
const scrollGesture = useNativeGesture({ enabled: scrollEnabled });
return (
{PHOTOS.map((photo) => (
))}
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
},
gallery: {
paddingHorizontal: 16,
gap: 16,
alignItems: 'center',
},
photo: {
width: WIDTH,
height: HEIGHT,
borderRadius: 16,
justifyContent: 'flex-end',
padding: 16,
},
label: {
color: 'white',
fontSize: 20,
fontWeight: '600',
},
});
```