react-native-gesture-handler: Pan gesture inside a ScrollView blocks scrolling

Description

I have a use case where I have a draggable element which I implement using Pan Gesture. This element is being rendered inside a ScrollView and block scrolling when attempting to scroll in the pan area (of the element)

Assuming that the pan conflicts with the scroll I tried to approach it differently and added a LongPress gesture that once started enables the panning, so as long as the user didn’t long press the element, the pan gesture should not block the scrolling.

I pretty much implemented this example with minor changes https://docs.swmansion.com/react-native-gesture-handler/docs/gesture-composition#race

This is the LongPress gesture

  const longPressGesture = Gesture.LongPress()
    .onStart(() => {
      isDragging.value = true;
    });

This is the Pan gesture

  const dragGesture = Gesture.Pan()
    .onStart(() => {...})
    .onUpdate(event => {....})
    .onFinalize(() => {
         isDragging.value = false;
    })
    .simultaneousWithExternalGesture(longPressGesture);

And finally

const composedGesture = Gesture.Race(dragGesture, longPressGesture);

<GestureDetector gesture={composedGesture}>
  <View reanimated>{props.children}</View>
</GestureDetector>

I was thinking on invoking dragGesture.enabled(false/true) to enable/disable the panning, but TBH, I’m not sure where to call it. Any ideas how to approach this?

Platforms

  • iOS
  • Android
  • Web

Screenshots

Steps To Reproduce

  1. Either use the code mention in the description in order to reproduce the full problem, or use the one from this guide and wrap the component with a ScrollView
  2. The code snippet below is more focused on the issue with a ScrollView that wraps a pan gesture component and how it blocks the scrolling

Expected behavior

Actual behavior

Snack or minimal code example

This is a small code snippet that demonstrate the general issue of pan/scroll not working together. Scrolling on the DragComponent area will not work, only below it will work

const DragComponent = props => {
  const gesture = Gesture.Pan();

  return (
    <GestureDetector gesture={gesture}>
      <View reanimated>{props.children}</View>
    </GestureDetector>
  );
};

const Main = () => {
    return (
      <ScrollView>
        <DragComponent>
          <View style={{width: '100%', height: 400, backgroundColor: 'red'}}/>
        </DragComponent>
      </ScrollView>
    );
}

Package versions

  • React: 17.0.2
  • React Native: 0.66.4
  • React Native Gesture Handler: 2.3.0
  • React Native Reanimated: 2.4.1

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 3
  • Comments: 16 (4 by maintainers)

Most upvoted comments

Hi! Gestures blocking scrolling when they are placed inside ScrollView is by design, otherwise you wouldn’t be able to use them. The idea of requiring LongPress before Pan can activate is a good way to get around that, but unfortunately, you cannon accomplish this using Race and Simultaneous modifiers. You can accomplish this using touch events:

const TOUCH_SLOP = 5;
const TIME_TO_ACTIVATE_PAN = 400;

const DragComponent = (props) => {
  const touchStart = useSharedValue({ x: 0, y: 0, time: 0 });

  const gesture = Gesture.Pan()
    .manualActivation(true)
    .onTouchesDown((e) => {
      touchStart.value = {
        x: e.changedTouches[0].x,
        y: e.changedTouches[0].y,
        time: Date.now(),
      };
    })
    .onTouchesMove((e, state) => {
      if (Date.now() - touchStart.value.time > TIME_TO_ACTIVATE_PAN) {
        state.activate();
      } else if (
        Math.abs(touchStart.value.x - e.changedTouches[0].x) > TOUCH_SLOP ||
        Math.abs(touchStart.value.y - e.changedTouches[0].y) > TOUCH_SLOP
      ) {
        state.fail();
      }
    })
    .onUpdate(() => {
      console.log('pan update');
    });

  return (
    <GestureDetector gesture={gesture}>
      <View>{props.children}</View>
    </GestureDetector>
  );
};

const Main = () => {
  return (
    <ScrollView>
      <DragComponent>
        <View style={{ width: '100%', height: 400, backgroundColor: 'red' }} />
      </DragComponent>
      <View style={{ width: '100%', height: 1000, backgroundColor: 'blue' }} />
    </ScrollView>
  );
};

Hey all, just thought I’d let you know I found another way which only requires a single Gesture plus doesn’t require the long press trigger (tested on ios sim + physical device but not Android):

const Component: FC = () => {
  const initialTouchLocation = useSharedValue<{ x: number, y: number } | null>(null);
  const panGesture = Gesture.Pan()
    .manualActivation(true)
    .onBegin((evt) => {
      initialTouchLocation.value = { x: evt.x, y: evt.y };
    })
    .onTouchesMove((evt, state) => {
      // Sanity checks
      if (!initialTouchLocation.value || !evt.changedTouches.length) {
        state.fail();
        return;
      }

      const xDiff = Math.abs(evt.changedTouches[0].x - initialTouchLocation.value.x);
      const yDiff = Math.abs(evt.changedTouches[0].y - initialTouchLocation.value.y);
      const isHorizontalPanning = xDiff > yDiff;
      
      if (isHorizontalPanning) {
        state.activate();
      } else {
        state.fail();
      }
    })
    .onStart(() => console.log('Horizontal panning begin'))
    .onChange(() => console.log('Pan change'))
    .onEnd(() => console.log('No cleanup required!'));
  };

  return (
    <ScrollView>
      <GestureDetector gesture={panGesture}>
        <View>
          {/* Your horizontally pan-able content */}
        </View>
      </GestureDetector>
    </ScrollView>
  );
};

Amazing! Thank you! I ended up combining your suggestion with my implementation.

I wanted to avoid implementing a LongPress behavior so I did the following and it works great!

const isDragging = useSharedValue(false);

const longPressGesture = Gesture.LongPress()
    .onStart(() => {
      isDragging.value = true;
    })
    .minDuration(250);


  const dragGesture = Gesture.Pan()
    .manualActivation(true)
    .onTouchesMove((_e, state) => {
      if (isDragging.value) {
        state.activate();
      } else {
        state.fail();
      }
    })
    .onStart(() => {...})
    .onUpdate(event => {...})
    .onEnd(() => {...})
    .onFinalize(() => {
      isDragging.value = false;
    })
    .simultaneousWithExternalGesture(longPressGesture);

  const composedGesture = Gesture.Race(dragGesture, longPressGesture);
   <GestureDetector gesture={composedGesture}>
    <View reanimated>{props.children}</View>
  </GestureDetector>

Oh, sorry I didn’t notice that. I’ve tried Android first and the gestures didn’t work at all without the root view so I figured it must’ve been it 😅.

As for your question, yes you can make ScrollView work simultaneously with its children gestures using simultaneousHandlers prop. It accepts an array of references to the gestures so you will need to slightly modify your code.

Here's what I did
import { StyleSheet, Text, View } from "react-native";

import {
  Skia,
  Group,
  useComputedValue,
  useValue,
  Line,
  Canvas,
  Circle,
  Fill,
  LinearGradient,
  Path,
  vec,
  useSharedValueEffect,
} from "@shopify/react-native-skia";
import React, { useMemo, useRef } from "react";
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
  ScrollView
} from "react-native-gesture-handler";
import { useSharedValue } from "react-native-reanimated";

export default function App() {
  const panRef = useRef(null)
  const lpRef = useRef(null)

  return (
    <GestureHandlerRootView style={styles.container}>
      <ScrollView simultaneousGestures={[panRef, lpRef]}>
        <Slider height={400} width={400} panRef={panRef} lpRef={lpRef} />
        <View style={styles.box} />
        <View style={styles.box} />
        <View style={styles.box} />
      </ScrollView>
    </GestureHandlerRootView>
  );
}

const Slider = ({ height, width, panRef, lpRef }) => {
  const path = useMemo(
    () => createGraphPath(width, height, 60, false),
    [height, width]
  );

  const touchPos = useValue(
    getPointAtPositionInPath(width / 2, width, 60, path)
  );

  const lineP1 = useComputedValue(
    () => vec(touchPos.current.x, touchPos.current.y + 14),
    [touchPos]
  );
  const lineP2 = useComputedValue(
    () => vec(touchPos.current.x, height),
    [touchPos]
  );

  const xPosShared = useSharedValue(width / 2);

  useSharedValueEffect(() => {
    touchPos.current = getPointAtPositionInPath(
      xPosShared.value,
      width,
      60,
      path
    );
  }, xPosShared);

  const isDragging = useSharedValue(false);

  const longPressGesture = Gesture.LongPress()
    .onStart(() => {
      isDragging.value = true;
    })
    .minDuration(250)
    .withRef(lpRef);

  const dragGesture = Gesture.Pan()
    .manualActivation(true)
    .onTouchesMove((e, state) => {
      if (isDragging.value) {
        state.activate();
        xPosShared.value = e.changedTouches[0].x;
      } else {
        state.fail();
      }
    })
    .onStart(() => {
      console.log("onStart!");
    })
    .onUpdate((event) => {
      console.log("onUpdate!");
    })
    .onEnd(() => {
      console.log("onEnd!");
    })
    .onFinalize(() => {
      isDragging.value = false;
    })
    .withRef(panRef)
    .simultaneousWithExternalGesture(longPressGesture);

  const composedGesture = Gesture.Race(dragGesture, longPressGesture);

  return (
    <View style={{ height, marginBottom: 10 }}>
      <GestureDetector gesture={composedGesture}>
        <Canvas style={styles.graph}>
          <Fill color="black" />
          <Path
            path={path}
            strokeWidth={4}
            style="stroke"
            strokeJoin="round"
            strokeCap="round"
          >
            <LinearGradient
              start={vec(0, height * 0.5)}
              end={vec(width * 0.5, height * 0.5)}
              colors={["black", "#DA4167"]}
            />
          </Path>
          <Group color="#fff">
            <Circle c={touchPos} r={10} />
            <Circle color="#DA4167" c={touchPos} r={7.5} />
            <Line p1={lineP1} p2={lineP2} />
          </Group>
        </Canvas>
      </GestureDetector>
      <Text>Touch and drag to move center point</Text>
    </View>
  );
};

const getPointAtPositionInPath = (x, width, steps, path) => {
  const index = Math.max(0, Math.floor(x / (width / steps)));
  const fraction = (x / (width / steps)) % 1;
  const p1 = path.getPoint(index);
  if (index < path.countPoints() - 1) {
    const p2 = path.getPoint(index + 1);
    // Interpolate between p1 and p2
    return {
      x: p1.x + (p2.x - p1.x) * fraction,
      y: p1.y + (p2.y - p1.y) * fraction,
    };
  }
  return p1;
};

const createGraphPath = (width, height, steps, round = true) => {
  const retVal = Skia.Path.Make();
  let y = height / 2;
  retVal.moveTo(0, y);
  const prevPt = { x: 0, y };
  for (let i = 0; i < width; i += width / steps) {
    // increase y by a random amount between -10 and 10
    y += Math.random() * 30 - 15;
    y = Math.max(height * 0.2, Math.min(y, height * 0.7));

    if (round && i > 0) {
      const xMid = (prevPt.x + i) / 2;
      const yMid = (prevPt.y + y) / 2;
      retVal.quadTo(prevPt.x, prevPt.y, xMid, yMid);
      prevPt.x = i;
      prevPt.y = y;
    } else {
      retVal.lineTo(i, y);
    }
  }
  return retVal;
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    alignItems: "center",
    justifyContent: "center",
    marginTop: 50,
  },
  box: {
    height: 400,
    width: 400,
    backgroundColor: "blue",
    margin: 4,
  },
  graph: {
    flex: 1,
  },
});

The bad news is, that you may need to eject to see it working as there is a problem with relations between gestures in the Expo Go app (it will work on the production build and there is a possibility that it will work when using a custom dev client).

Hey all, just thought I’d let you know I found another way which only requires a single Gesture plus doesn’t require the long press trigger (tested on ios sim + physical device but not Android):

const Component: FC = () => {
  const initialTouchLocation = useSharedValue<{ x: number, y: number } | null>(null);
  const panGesture = Gesture.Pan()
    .manualActivation(true)
    .onBegin((evt) => {
      initialTouchLocation.value = { x: evt.x, y: evt.y };
    })
    .onTouchesMove((evt, state) => {
      // Sanity checks
      if (!initialTouchLocation.value || !evt.changedTouches.length) {
        state.fail();
        return;
      }

      const xDiff = Math.abs(evt.changedTouches[0].x - initialTouchLocation.value.x);
      const yDiff = Math.abs(evt.changedTouches[0].y - initialTouchLocation.value.y);
      const isHorizontalPanning = xDiff > yDiff;
      
      if (isHorizontalPanning) {
        state.activate();
      } else {
        state.fail();
      }
    })
    .onStart(() => console.log('Horizontal panning begin'))
    .onChange(() => console.log('Pan change'))
    .onEnd(() => console.log('No cleanup required!'));
  };

  return (
    <ScrollView>
      <GestureDetector gesture={panGesture}>
        <View>
          {/* Your horizontally pan-able content */}
        </View>
      </GestureDetector>
    </ScrollView>
  );
};

ı removed state.fail() and added it to onTouchesUp callback but it also works without state.fail(). I tested it android physical device and fully worked.

Hello @j-piasecki, how do you get this to work with a third-party list library (say flash-list)? These libraries do not support simultaneousHandlers prop.

Hi everyone, this is my solution. In all previous solutions, I’ve encountered a problem: the action fails if my finger doesn’t move straight. My idea is to check isHorizontalPanning only within 100ms so that your finger can move smoothly without worrying about being perfectly straight

P/S. Give me a reaction if you like this.

        const TIME_TO_ACTIVATE_PAN = 100
	const touchStart = useSharedValue({ x: 0, y: 0, time: 0 })

	const taskGesture = Gesture.Pan()
		.manualActivation(true)
		.onBegin(e => {
			touchStart.value = {
				x: e.x,
				y: e.y,
				time: Date.now(),
			}
		})
		.onTouchesMove((e, state) => {
			const xDiff = Math.abs(e.changedTouches[0].x - touchStart.value.x)
			const yDiff = Math.abs(e.changedTouches[0].y - touchStart.value.y)
			const isHorizontalPanning = xDiff > yDiff
			const timeToCheck = Date.now() - touchStart.value.time

			if (timeToCheck <= TIME_TO_ACTIVATE_PAN ) {
				if (isHorizontalPanning) {
					state.activate()
				} else {
					state.fail()
				}
			}
		})
		.onUpdate(event => (translateX.value = event.translationX))
		.onEnd(() => {
			const shouldBeDismissed = translateX.value < TRANSLATE_X_THRESHOLD
			if (shouldBeDismissed) {
				translateX.value = withTiming(TRANSLATE_X_THRESHOLD)
			} else {
				translateX.value = withTiming(0)
			}
		})

@j-piasecki That did the trick! Thank you so much 🎉

Since I got it to work simultaneously using this solution I even removed the long press gesture and only have the pan gesture now which is no longer manually activated. Now the pan and scrolling works perfectly at the same time.

For me this even works in the Expo Go shell but I’ll keep in mind that if gesture logic looks off in Expo Go then I should also try a dev-client build to see if it persists.

Great! And yeah, your solution looks much cleaner. Since you’ve solved the problem, I’ll close the issue.