Closed ethanshar closed 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>
);
};
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>
Great! And yeah, your solution looks much cleaner. Since you've solved the problem, I'll close the issue.
@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!
@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.
@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?
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.
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).
@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 @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.
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>
);
};
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.
@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
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
},
});
}
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;
}
})
//...
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)
}
})
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
})
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
This is the Pan gesture
And finally
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
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
Package versions