Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pan gesture inside a ScrollView blocks scrolling #1933

Closed
2 of 3 tasks
ethanshar opened this issue Mar 16, 2022 · 20 comments
Closed
2 of 3 tasks

Pan gesture inside a ScrollView blocks scrolling #1933

ethanshar opened this issue Mar 16, 2022 · 20 comments
Labels
Platform: Android This issue is specific to Android Platform: iOS This issue is specific to iOS Repro provided A reproduction with a snack or repo is provided

Comments

@ethanshar
Copy link

ethanshar commented Mar 16, 2022

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
@github-actions github-actions bot added Platform: Android This issue is specific to Android Platform: iOS This issue is specific to iOS Repro provided A reproduction with a snack or repo is provided Missing info and removed Missing info labels Mar 16, 2022
@j-piasecki
Copy link
Member

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
Copy link
Author

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
Copy link
Member

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

@Jorundur
Copy link

@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
Copy link
Member

@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
Copy link

Jorundur commented Sep 13, 2022

@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
Copy link
Member

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

@Jorundur
Copy link

@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
Copy link

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
Copy link

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
Copy link

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
Copy link

didadev commented Jan 30, 2024

@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
Copy link

stefoid commented Feb 5, 2024

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
Copy link

ricardoracki commented Apr 2, 2024

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
Copy link

UIT19521334 commented Apr 24, 2024

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
Copy link

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
             })

@zebriot
Copy link

zebriot commented Aug 8, 2024

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.

You can do something like this,

  const nativeGesture = Gesture.Native();
  const composedGestures = Gesture.Simultaneous(myPanGesture, nativeGesture);
  const ScrollComponent = (props: any) => {
    return (
     <GestureDetector gesture={composedGestures}>
      <ScrollView {...props} />
      </GestureDetector>
    );
  };

  return (
        <Flashlist
          renderScrollComponent={ScrollComponent}
            {...otherProps}
        />
  );

This will handle both gestures simultaneously, I am just using translationX in my myPanGesture gesture so i havent tried yet with others.

@sjymon
Copy link

sjymon commented Aug 21, 2024

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)
			}
		})

Hello, I'm also facing a similar issue. I'm using react-native-reanimated-carousel to create a carousel. However, when I swipe left or right, the onPress event of the Item is affected. I used PanGestureHandler to handle the swipe of the Item, but then the swipe event of the carousel doesn't work anymore. Could you help me look into this case? Thank you very much.

<Carousel
        loop={true}
        style={{ width: screenWidth, height: screenHeight / 3 }}
        width={screenWidth}
        data={loopData}
        onSnapToItem={(index) => {
          setCurrentIndex(index);
        }}
        panGestureHandlerProps={{
          simultaneousHandlers: panRef,
        }}
        renderItem={({ index, animationValue }) => {
          return (
            <ItemVideo
              item={loopData[index]}
              index={index}
              setParentPlaying={setParentPlaying}
              currentIndex={currentIndex}
              animationValue={animationValue}
              tap={panGesture}
            />
          );
        }}
        customAnimation={animationStyle}
        scrollAnimationDuration={1200}
        onScrollBegin={() => {
          console.log("onScrollBegin");
          setScrolling(true);
        }}
        onScrollEnd={() => {
          console.log("onScrollEnd");
          setScrolling(false);
        }}
      />

ItemVideo

<Animated.View style={[maskStyle]}>
          <VideoPlayer
            videoRef={videoRef}
            source={item.source}
            isPlayingVideo={isPlayingVideo}
            setIsPlayingVideo={setIsPlayingVideo}
            isFullscreen={isFullscreen}
            setIsFullscreen={setIsFullscreen}
          />
        </Animated.View>
        {orientation === OrientationType["PORTRAIT"] && (
          <>
            {!isPlayingVideo && (
               <PanGestureHandler
                onGestureEvent={handleGestureEvent}
                onHandlerStateChange={handleGestureStateChange}
              />
          
                <Pressable
                  onPress={handleTapVideo}
                  // onPressIn={handlePressIn}
                  // onPressOut={handlePressOut}
                >
                  <ImageThumbnail>
                    <ImageCarousel source={item.thumbnail} />
                  </ImageThumbnail>
                </Pressable>
          
              </PanGestureHandler>
            )}
          </>
        )}

@navk5650
Copy link

navk5650 commented Sep 16, 2024

My perfect solutions

imports....
import Animated, { runOnJS, runOnUI, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';

type SwappableProps = {
id?: string;
close?: boolean;
children: React.ReactNode;
onSwipe?: (index: number) => void;
enableLeftSwipe?: boolean;
enableRightSwipe?: boolean;
leftSwipeChild?: React.ReactNode;
rightSwipeChild?: React.ReactNode;
onOpen?: (id?: string) => void;
};

const THRESHHOLD = width * 0.05;
const END_POSITION = width * 0.15;

const Swippable: React.FC = ({id, onOpen, close, children, enableLeftSwipe, enableRightSwipe, rightSwipeChild, leftSwipeChild}) => {
const defaultPosition = useSharedValue(0);
const position = useSharedValue(0);

useEffect(() => {
  if (close) {
    position.value = withTiming(0, { duration: 70 });
  }
}, [close])

const panGesture = Gesture
.Pan()
.manualActivation(true)
.onBegin((event) => {
  defaultPosition.value = event.absoluteX;
})
.onTouchesDown(() => {
    position.value = withTiming(0);
})
.onTouchesMove((event, state) => {
  const displacementX = Math.abs(
    defaultPosition.value - event.changedTouches[0].absoluteX
  );
  if (displacementX > 5) state.activate();
})
.onUpdate((e) => {
    if (!enableRightSwipe && e.translationX >= 0) {
        return;
    }
    if (!enableLeftSwipe && e.translationX <= 0) {
        return;
    }
    if (position.value > END_POSITION || position.value < -END_POSITION) {
        return;
    }
    position.value = e.translationX;
})
.onEnd(() => {
  if (position.value >= THRESHHOLD) {
    position.value = withTiming(END_POSITION, { duration: 100 }, 
      () => {
        onOpen ? runOnJS(onOpen)(id) : null;
      }
    );
  } else if (position.value <= -THRESHHOLD) {
    position.value = withTiming(-END_POSITION, { duration: 100 },
      () => {
        onOpen ? runOnJS(onOpen)(id) : null;
      }
    );
  } else {
    position.value = withTiming(0, { duration: 100 });
  }
});

const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: position.value }],
};
});

return (

{/* Background Buttons */}



{rightSwipeChild}






{leftSwipeChild}


  {/* Swipable Card */}
  <GestureDetector gesture={panGesture} >
    <Animated.View style={animatedStyle} >
    {children}
    </Animated.View>
  </GestureDetector>
</View>

);
};

export default Swippable;

@3056810551
Copy link

你好 @j-piasecki,你是如何让这个与第三方列表库(比如 flash-list)一起工作?这些库不支持 simultaneousHandlers 属性。

你可以做类似这样的事情,

  const nativeGesture = Gesture.Native();
  const composedGestures = Gesture.Simultaneous(myPanGesture, nativeGesture);
  const ScrollComponent = (props: any) => {
    return (
     <GestureDetector gesture={composedGestures}>
      <ScrollView {...props} />
      </GestureDetector>
    );
  };

  return (
        <Flashlist
          renderScrollComponent={ScrollComponent}
            {...otherProps}
        />
  );

这将同时处理这两种手势,我在我的 myPanGesture 手势中只使用了 translationX,所以我还没有尝试其他的。

Your approach is fantastic! In 2024, I successfully solved this problem, and it's definitely the simplest solution, without a doubt!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Platform: Android This issue is specific to Android Platform: iOS This issue is specific to iOS Repro provided A reproduction with a snack or repo is provided
Projects
None yet
Development

No branches or pull requests