software-mansion / react-native-gesture-handler

Declarative API exposing platform native touch and gesture system to React Native.
https://docs.swmansion.com/react-native-gesture-handler/
MIT License
5.85k stars 954 forks source link

Pan gesture inside a ScrollView blocks scrolling #1933

Closed ethanshar closed 2 years ago

ethanshar commented 2 years ago

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

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

j-piasecki commented 2 years ago

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>
  );
};
ethanshar commented 2 years ago

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>
j-piasecki commented 2 years ago

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

Jorundur commented 1 year ago

@ethanshar @j-piasecki Apologies for bringing this issue back from the dead, but I'm having the exact same problem (need to detect pan gestures inside a scrollview) and the ScrollView seems to entirely blocked from scrolling when touching the area with the GestureDetector.

I've tried both of your solutions with the same results.

I have an example repo with this problem https://github.com/Jorundur/expo-gesture-handler-skia-test, just need to run yarn and then npx expo start.

Can you see what's incorrect with my implementation? Any help would be much appreciated!

j-piasecki commented 1 year ago

@Jorundur You're missing GestureHandlerRootView, after changing the body of App function like so:

export default function App() {
  return (
    <GestureHandlerRootView style={styles.container}>
      <ScrollView>
        <Slider height={400} width={400} />
        <View style={styles.box} />
        <View style={styles.box} />
        <View style={styles.box} />
      </ScrollView>
    </GestureHandlerRootView>
  );
}

It seems to be working correctly.

Jorundur commented 1 year ago

@j-piasecki Unfortunately that still doesn't make it work, but thank you for pointing that out though, it was of course missing! I've pushed that up to my repo now.

The issue can still be seen when you try to scroll starting from the graph area, i.e. if you press it and start scrolling before 250ms have passed (since that's when the longPress activates and the pan for the graph kicks in). The screen doesn't scroll for me at all if I try scrolling when touching the graph area.

Also - I have a question about something you said in a previous comment:

Hi! Gestures blocking scrolling when they are placed inside ScrollView is by design, otherwise you wouldn't be able to use them.

This makes a lot of sense for probably most use cases, for example the original one in this issue where a user has to drag some stuff around inside a ScrollView. But in my use case, what I would really like (even more than the solutions posted above) is for the ScrollView gestures to simply work simultaneously with the custom pan gestures for a child inside the ScrollView. This is because the pan gesture is only for moving a cursor horizontally along a graph so it doesn't really matter if the ScrollView scrolls slightly while the user is moving the cursor in the graph, i.e.:

1) If the user scrolls down diagonally starting from the graph area, it's fine that the graph cursor moves horizontally the appropriate distance 2) If the user is dragging horizontally in the graph and they move their finger a bit up/down, it's expected that the ScrollView scrolls a bit

Is there any way to allow ScrollView scrolling to just work simultaneously with children pan gestures? I.e. some flag on the ScrollView or something?

j-piasecki commented 1 year ago

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 ```jsx 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 ( ); } 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 ( Touch and drag to move center point ); }; 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).

Jorundur commented 1 year ago

@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.

dmahajan980 commented 1 year ago

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.

pyramid-scheme-ceo commented 11 months ago

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>
  );
};
turkergercik commented 11 months ago

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.

didadev commented 3 months ago

@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.

Hello, I have the exact same issue, and I can't get it to work with the solution above. How did you got it? Thanks

stefoid commented 3 months ago

my two cents - not a universal solution, but its simple and it worked for me as a way or recognising 'horizontal 'swipes' on a screen that vertically scrolled. As in swipe left to go forward a page, swipe right to go back.

The problem was either the scrollview responder tended to swallow all events, preventing swipe recognition, or if I set my 'swipe handler' to capture events then it would swallow all events preventing scrolling.

The solution was for the swipe handler to never respond true to onStartShouldSetPanResponderCapture. That way it wouldnt interfere with scrolling. In this situation, the swipe handler still gets calls to onMoveShouldSetPanResponder and I used this function to test dx/dy for something that looks like a swipe, and if it did, then I perfromed that swipe. The summary is you opt out of the start/move/release lifecycle of a gesture, and just sniff move for something that looks like the gesture you are interested in.

the wrinkle is that move could be called multiple times within a single gesture, but once you decide that your gesture has been recognized, you only want to act on that one time, not once for each call to move. So just set a flag in onStartShouldSetPanResponderCapture to start listening for your gesture, and then clear that flag once it is recognized, so as to only act on it once.

something list this:

    this.listeningForSwipeMoves = false

    this.panResponder = PanResponder.create({
        // I believe this controls whether we are interested in listening for gesture events, which would be yes
        onStartShouldSetPanResponder: (evt, gestureState) => {
            return true
        },

        // if a gesture starts, dont capture the events - it will block scroll responders, but do set the flag for gesturestarted
        onStartShouldSetPanResponderCapture: (evt, gestureState) => {
            this.listeningForSwipeMoves = true
            return false
        },

        // the following two functions seem to be called whether we are capturing events or not
        onMoveShouldSetPanResponder: (evt, gestureState) => {
            let isSwipe = ...performCheckForSwipeUsingDxDy

            if (this.listeningForSwipeMoves && isSwipe) {
                console.log("SWIPING")  // this looks like a swipe, so do the sipe thing you need to do
                this.listeningForSwipeMoves = false  //  dont keep reacting to further events until a new gesture starts
            }

            return false  
        },
        onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
            return false
        },
    });
}
ricardoracki commented 1 month ago

My solution is working

//...
const MAX_DISPLACEMENT_X = 5;
  const initialScrollX = useSharedValue(0);
  const pan = Gesture.Pan()
    .manualActivation(true)
    .onBegin((event) => {
      initialScrollX.value = event.absoluteX;
    })
    .onTouchesMove((event, state) => {
      const displacementX = Math.abs(
        initialScrollX.value - event.changedTouches[0].absoluteX
      );
      if (displacementX > MAX_DISPLACEMENT_X) state.activate();
    })
    .onUpdate((event) => {
      if (event.translationX < 0 && event.absoluteX > OVERDRAG) {
        translateX.value = event.translationX;
      }
    })
//...
UIT19521334 commented 2 weeks ago

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)
            }
        })
varunkukade commented 2 weeks ago

In my case, following improvisation worked. I added additional check of isDragging. If user is dragging the item, state will never fail unless user end the drag.

    const initialTouchLocation = useSharedValue<{ x: number; y: number; } | null>(null);
    const isDragging = useSharedValue(false)

    const taskGesture = Gesture.Pan()
    .manualActivation(true)
    .onTouchesDown(e => {
                initialTouchLocation.value = {
                x: e.changedTouches[0].x,
                y: e.changedTouches[0].y,
              };
             })
    .onTouchesMove((evt, state) => {
              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 {
                                     if(!isDragging.value) state.fail()
                                     else state.activate()
            }
    })
    .onStart(event => {
                isDragging.value = true
             })
             .onEnd(event => {
                isDragging.value = false
             })