In React Native, a PanResponder drawing canvas inside a vertical ScrollView keeps losing strokes to scrolling; why, and how do you stop it?
answer
- two layers compete for the touch
- ScrollView asks for the responder too
- TerminationRequest defaults to letting go
- onShouldBlockNativeResponder is Android-only
- toggle scrollEnabled during a stroke
basics
~20 sThe ScrollView competes on two layers: it asks for the JS responder once it scrolls, and the native scroll gesture can cancel the touch. Refuse in onPanResponderTerminationRequest, keep the Android block default, and disable scrollEnabled while a stroke is in progress.
solid answer
~50 sTwo things take the touch. On the JavaScript side, `ScrollView` has its own responder handlers and asks for the responder once it sees scrolling while a finger is down; the canvas's `onPanResponderTerminationRequest` defaults to true, so it hands over and gets `onPanResponderTerminate` mid-stroke. Returning false there refuses other JS responders. On the native side, the platform scroll view can take the touch regardless of JS. On Android, `onShouldBlockNativeResponder` (default true) stops the canvas's parents from intercepting once it is granted, which is why the code often behaves there. The flag is Android-only, and on iOS the native scroll gesture can still recognise and cancel the touch, which arrives as a terminate nobody asked for. The dependable fix is to claim on touch start and set the ScrollView's `scrollEnabled` to false for the duration of a stroke, restoring it on release and on terminate, or to keep the canvas out of the scrolling area.
code
tsx · 44 linesimport {useRef, useState} from 'react';
import {PanResponder, ScrollView, StyleSheet, Text, View} from 'react-native';
type Point = {x: number; y: number};
function SketchCanvas({onDrawingChange}: {onDrawingChange: (drawing: boolean) => void}) {
const stroke = useRef<Point[]>([]);
const pan = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
stroke.current = [];
onDrawingChange(true);
},
onPanResponderMove: evt => {
const {locationX, locationY} = evt.nativeEvent;
stroke.current.push({x: locationX, y: locationY});
},
onPanResponderRelease: () => onDrawingChange(false),
onPanResponderTerminate: () => {
stroke.current = [];
onDrawingChange(false);
},
}),
).current;
return <View style={styles.canvas} {...pan.panHandlers} />;
}
export function LessonScreen() {
const [drawing, setDrawing] = useState(false);
return (
<ScrollView scrollEnabled={!drawing}>
<Text style={styles.prompt}>Draw a cat with a hat</Text>
<SketchCanvas onDrawingChange={setDrawing} />
</ScrollView>
);
}
const styles = StyleSheet.create({
canvas: {height: 400, backgroundColor: 'white'},
prompt: {fontSize: 20, padding: 16},
});go deeper
Recall that a ScrollView can take a touch from a view inside it, and that scrollEnabled can switch its scrolling off.
Explain the termination request default and what onShouldBlockNativeResponder does, including that it is Android-only.
Separate the two layers, JS responder negotiation and native gesture recognition, and ship a fix that restores scrolling on every exit path.
Judge whether nested scroll and draw regions belong in the product at all, or whether a layout change or explicit gesture composition is the cheaper long-term answer.
## The symptom A kids' sketch app shows a lesson as a vertical `ScrollView`: a prompt such as "draw a cat with a hat", a drawing canvas, then a "done" button further down. Horizontal strokes work, but any stroke with a vertical component scrolls the page and the line stops halfway. Often it works on one platform and fails on the other. ## Two layers compete for the touch A touch on the canvas is contested on **two layers** at once: - **The JavaScript responder system.** The canvas becomes responder through PanResponder. `ScrollView` also takes part: its component implements responder handlers and asks for the responder once scrolling happens while a finger is down. That request goes to the canvas's `onPanResponderTerminationRequest`, and if the canvas does not answer, the default is to **let go**. - **The native gesture layer.** The platform scroll view recognises a pan on its own, independently of JavaScript. If it wins, the canvas's touch is cancelled, and the canvas receives `onPanResponderTerminate` **without being asked**. Fixing only one layer explains the "works on Android only" reports. ## What each setting does | Setting | Layer | Platform | Effect | |---|---|---|---| | `onPanResponderTerminationRequest: () => false` | JavaScript | both | refuses other JS responders, including ScrollView's JS claim | | `onShouldBlockNativeResponder` (default `true`) | native | Android only, per the docs | after the grant, React Native stops the canvas's parents from intercepting the touch | | `scrollEnabled={false}` on the ScrollView while drawing | native | both | the scroll view cannot scroll, so nothing can cancel the stroke | | `disableScrollViewPanResponder` on the ScrollView | JavaScript | both | stops ScrollView's JS-side responder claims; native scrolling is unaffected | On Android, the grant's block flag makes React Native call the platform's "disallow intercept" request on the canvas's parent chain, so a vertical `ScrollView` does not take the touch once the canvas owns it. There is no equivalent on iOS, where the scroll view's pan recogniser can still begin and cancel the canvas's touches. ## A recipe that works on both platforms 1. **Claim on start.** Return `true` from `onStartShouldSetPanResponder`, so the canvas is responder before any scrolling begins, rather than waiting to claim on move. 2. **Refuse JS handoffs.** Return `false` from `onPanResponderTerminationRequest` while drawing. 3. **Leave `onShouldBlockNativeResponder` at its default** of `true`, so Android's parents do not intercept. 4. **Turn scrolling off for the stroke.** On grant, tell the screen to set `scrollEnabled` to `false`; on release **and** on terminate, set it back to `true`. Forgetting terminate leaves the lesson page permanently unscrollable after an interruption. 5. **Handle terminate anyway.** The OS can still cancel a touch, for example for a system gesture, so the partial stroke must be cleaned up there. ## Design alternatives - **Keep the canvas out of the scroll area:** a fixed canvas with the lesson text in a collapsible header avoids the conflict entirely. - **Scroll with two fingers:** the canvas owns one-finger touches and the page scrolls on two, which needs coordinated gesture handling. - **Explicit gesture composition:** a UI-thread gesture library lets you declare that the drawing pan and the scroll view's pan must not run together, instead of negotiating per touch. ## What interviewers listen for - That the candidate separates the JS responder negotiation from native gesture recognition. - That they know the termination request only arbitrates between JavaScript responders. - That they know the block flag is Android-only. - That the scroll toggle is restored on terminate as well as on release. ## Why claiming on start matters A canvas that only claims in `onMoveShouldSetPanResponder` is asked after the finger has already moved. By then the platform scroll view may have started recognising a pan of its own, and the canvas is competing for a touch that is already being scrolled. Claiming in `onStartShouldSetPanResponder` makes the canvas the JavaScript responder on finger-down, which is also the moment its grant runs, the Android block takes effect and the screen can switch `scrollEnabled` off. The cost is that a tap on the canvas also becomes a stroke, which for a drawing surface is the behaviour you want: a tap should leave a dot.
- Why does this canvas often work on Android but not on iOS with PanResponder's defaults?When the canvas is granted, PanResponder returns `onShouldBlockNativeResponder`, which defaults to true. On Android, React Native then asks the canvas's parents not to intercept the touch, so the vertical ScrollView cannot take it. The docs mark the flag as Android-only; on iOS nothing blocks the native scroll recogniser, so it can start scrolling and cancel the canvas's touch.
- What does ScrollView's disableScrollViewPanResponder change, and why is it not enough here?It turns off ScrollView's JavaScript-side responder handlers, which by default claim touches from views deeper in the tree to avoid accidental taps while scrolling. It does not stop the native scroll view from scrolling. The canvas can still lose strokes to a native scroll, so you still need `scrollEnabled` toggling or a different layout.
saying these in an interview costs you the question
- Returning false from onPanResponderTerminationRequest stops native scrolling on both platforms
- onShouldBlockNativeResponder works the same on iOS and Android
- Claiming on move instead of on start keeps the ScrollView from interfering
- onPanResponderTerminate only fires after the canvas agreed to give up the touch
- Turning scrollEnabled back on in onPanResponderRelease alone is enough