In React Native with Gesture Handler 3, how do you stop a pinch-to-zoom photo inside a horizontal pager from fighting the pager's swipe?
answer
- the pager needs a native gesture too
- use Gesture Handler's ScrollView
- block makes the pager wait
- enable the photo pan only when zoomed
- relations live under one root
basics
~20 sUse Gesture Handler's ScrollView as the pager so it has a native gesture, then give each photo's pinch and pan block: scrollGesture so the pager waits for them. Enable the pan only while zoomed, so unzoomed swipes still page.
solid answer
~50 sThe photo and the pager are different components, so composition hooks do not apply; you need cross-component relations. First, the pager must take part in recognition: Gesture Handler's `ScrollView` and `FlatList` come with a native gesture pre-applied, while React Native's own `ScrollView` does not accept relation props. The wrapped `ScrollView` hands you its gesture through `onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER`; store it with a guarded `setState` so you do not loop. Then give each photo's pinch and pan `block: scrollGesture`, which makes the pager wait until they fail or never begin, the documented pattern for gestures inside a list. Finally, make the photo's pan `enabled` only while the photo is zoomed, passing a shared value: at normal scale the pan never begins, so a horizontal swipe pages immediately, and when zoomed, dragging moves the photo. Everything must sit under one `GestureHandlerRootView`.
code
tsx · 70 linesimport {useState} from 'react';
import {useWindowDimensions} from 'react-native';
import {
GestureDetector,
ScrollView,
usePanGesture,
usePinchGesture,
useSimultaneousGestures,
type NativeGesture,
} from 'react-native-gesture-handler';
import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
type PhotoProps = {uri: string; scrollGesture: NativeGesture | null};
function ZoomablePhoto({uri, scrollGesture}: PhotoProps) {
const {width} = useWindowDimensions();
const scale = useSharedValue(1);
const saved = useSharedValue(1);
const offsetX = useSharedValue(0);
const zoomed = useSharedValue(false);
const pinch = usePinchGesture({
onUpdate: e => {
scale.value = Math.max(1, saved.value * e.scale);
},
onDeactivate: () => {
saved.value = scale.value;
zoomed.value = scale.value > 1;
},
block: scrollGesture ?? undefined,
});
const pan = usePanGesture({
enabled: zoomed,
onUpdate: e => {
offsetX.value += e.changeX;
},
block: scrollGesture ?? undefined,
});
const gesture = useSimultaneousGestures(pinch, pan);
const style = useAnimatedStyle(() => ({
transform: [{translateX: offsetX.value}, {scale: scale.value}],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.Image source={{uri}} resizeMode="contain" style={[{width, height: '100%'}, style]} />
</GestureDetector>
);
}
export function PhotoPager({uris}: {uris: string[]}) {
const [scrollGesture, setScrollGesture] = useState<NativeGesture | null>(null);
return (
<ScrollView
horizontal
pagingEnabled
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={g => {
if (!scrollGesture || scrollGesture.handlerTag !== g.handlerTag) {
setScrollGesture(g);
}
}}>
{uris.map(uri => (
<ZoomablePhoto key={uri} uri={uri} scrollGesture={scrollGesture} />
))}
</ScrollView>
);
}go deeper
Recall that gestures on different components need relation props, and that the pager must be Gesture Handler's ScrollView to take part.
Explain simultaneousWith, requireToFail and block, including that block is requireToFail in the reverse direction.
Design the whole viewer: get the pager's gesture safely, block it from each photo, gate the pan on zoom, and handle edge cases like paging at the image edge.
Decide whether a custom zoomable pager is worth owning versus a maintained component, given the gesture edge cases it brings.
## The conflict A photo gallery shows photos in a horizontal pager. Each photo supports **pinch to zoom** and, once zoomed, **pan to move around**. Three gestures now want the same horizontal movement: - the **pager's scroll**, which should change page; - the photo's **pan**, which should move a zoomed photo; - the photo's **pinch**, whose fingers also drift sideways. Left alone, the first to activate wins and cancels the rest, so users see a page change in the middle of a zoom, or a zoomed photo that cannot be moved. ## Why composition hooks do not help here `useSimultaneousGestures` and friends combine gestures on **one detector**. The pager and the photo are **different components**, so the tool is the set of **relation props** in each gesture's config: | Relation | Direction | Meaning | |---|---|---| | `simultaneousWith` | this gesture with others | may be active at the same time as them | | `requireToFail` | this gesture waits for others | activates only after they fail or never begin | | `block` | others wait for this gesture | they activate only after this one fails or never begins | `block` is `requireToFail` reversed, and the documentation recommends it for lists, where one scroll view must wait for many gestures underneath it. Each photo can declare it, instead of the pager listing every photo's gestures. ## Making the pager a participant 1. Use **Gesture Handler's `ScrollView`** (or `FlatList`) for the pager. These wrapped components have a native gesture pre-applied, so they take part in recognition and relations. React Native's core `ScrollView` has no gesture object to relate to. 2. Read the pager's gesture from **`onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER`**. As the name warns, calling `setState` unconditionally there re-creates the gesture and loops; compare `handlerTag` before storing it. 3. Pass that gesture down to every page. ## Declaring the photo's gestures - **Pinch:** `block: scrollGesture`, so the pager does not page while two fingers zoom. - **Pan:** `block: scrollGesture` **and** `enabled` set to a shared value that is `true` only while the photo is zoomed. `enabled` is evaluated when a touch starts; a disabled gesture is not extracted, so it never begins, and `block` has nothing to wait for. An unzoomed swipe therefore pages at once, while a zoomed drag moves the photo and holds the pager. - **Pinch and pan together** on the photo: combine them with `useSimultaneousGestures`, because they are on the same detector. ## Refinements - **Paging from the edge of a zoomed photo:** once the pan reaches the image edge, you may want the pager to take over. That needs logic in the pan, such as disabling it at the edge through the shared value, and is a product decision more than a library one. - **Double tap to zoom** on the photo belongs in an exclusive composition with any single tap on the same detector. - **One root view:** relations only work between gestures under the same `GestureHandlerRootView`. ## What interviewers listen for - Recognising that the pager must be a Gesture Handler component to be related at all. - Choosing `block` (or `requireToFail`) over hoping activation thresholds sort it out. - Gating the pan on zoom so normal paging stays instant. - Knowing about the infinite re-render trap when obtaining the scroll view's gesture. ## Why thresholds alone are not enough Pan gestures have activation thresholds such as `activeOffsetX`, `activeOffsetY`, `failOffsetX` and `failOffsetY`, and it is tempting to solve the conflict with them alone: - They decide **when a pan activates or fails**, for example failing if the finger moves vertically first. - They do **not** make another gesture wait. The pager's native gesture has its own criteria, and whichever activates first still cancels the other. - A pinch is not a pan at all, so pan thresholds say nothing about two fingers drifting sideways. Thresholds are still useful as a refinement, for instance a small `activeOffsetX` on the zoomed photo's pan so tiny jitters do not count as drags, but the ownership question is answered by relations, not by distances.
- In Gesture Handler 3, what is the difference between requireToFail and block?They express the same waiting relation in opposite directions. `requireToFail: other` on gesture A makes A wait until `other` fails or never begins. `block: other` on A makes `other` wait for A. `block` suits a list or pager, where each child can declare that the one scroll view must wait for it, instead of the scroll view listing every child's gestures.
- Why is onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER named that way, and how do you use it safely?It fires whenever the wrapped component's native gesture instance or configuration changes. Storing the gesture with `setState` re-renders the component, which can re-create the gesture and fire the callback again, looping forever. Store it only when the `handlerTag` differs from the stored one, as the library's own `ScrollView` does.
saying these in an interview costs you the question
- useSimultaneousGestures can relate a photo's pinch to the pager around it
- React Native's core ScrollView accepts requireToFail and block like Gesture Handler's
- Raising the pan's minDistance is enough to stop the pager stealing a zoom
- block makes the other gesture fail immediately instead of waiting
- Calling setState unconditionally in onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER is fine