Glazzes / react-native-zoom-toolkit

Smoothly zoom any image, video or component you want!
https://glazzes.github.io/react-native-zoom-toolkit/
MIT License
178 stars 11 forks source link

[Question] Using ResumableZoom inside a ScrollView, how to disable scroll when pinching? #68

Open lucianomlima opened 2 weeks ago

lucianomlima commented 2 weeks ago

Hello Again! As I comment on another issue I'm using react-native-snap-carousel to develop a photo gallery with zoom behavior.

SnapCarousel uses ScrollView with horizontal scroll and snap enabled. Each item of the carousel is a ResumableZoom with pinch and double tap enabled with 4x zoom scale.

On Android, when I start a pinch gesture, moving two fingers together, works properly. But if I move only 1 finger even if I have 2 touch points, the pinch is not detected and ScrollView starts scrolling to the next or previous photo depending on the direction of the moving finger.

I try to convert the SnapCarousel to an animated component but can't find a way to make it work. Is there any advice that you can give me in this situation?

Glazzes commented 2 weeks ago

Hello there, let's start for the most basic question, why are using ResumableZoom instead of the already built-in Gallery?

lucianomlima commented 2 weeks ago

It's an ongoing project that already has a carousel gallery. I tried to negotiate to change the carousel lib but didn't have success. Because of this, I'm trying to make things work together. But simply, I have the following structure (I'm going to create a snack to better tests):

Gallery Component ```ts function Gallery({ index, photos, onLoadEnd, gallerySync }) { const [currentIndex, setCurrentIndex] = useState(index); const [isZoomEnabled, setZoomEnabled] = useState(false); const resumableZoomRef = useRef([]); const timeoutID = useRef(); const navigation = useNavigation>(); const { width } = useWindowDimensions(); const { isLandscapeOrientation } = useDeviceOrientation(); function setGalleryIndex(newIndex: number) { if (currentIndex !== newIndex) { resumableZoomRef.current[currentIndex].reset(true); } gallerySync?.(newIndex); setCurrentIndex(newIndex); } function onResizeForIndex(itemIndex: number) { return function onResize(scale: number) { if (itemIndex !== currentIndex) return; if (timeoutID.current) { clearTimeout(timeoutID.current); timeoutID.current = undefined; } timeoutID.current = setTimeout(() => { setZoomEnabled(scale !== 1); }, 500); }; } const onPanEnd = useCallback((item: PhotoItem) => { tracker.trackEvent('partner_gallery_photo_scrolled', { photo_position: index, photo_id: item.id, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const onPinchEnd = useCallback((scale: number, item: PhotoItem) => { const event = scale > 1 ? 'zoom_in' : 'zoom_out'; tracker.trackEvent(`partner_gallery_photo_${event}`, { photo_position: index, photo_id: item.id, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // This is the component that uses ResumableZoom function renderItem({ item, index: itemIndex }) { return ( onPanEnd(item)} onPinchEnd={e => onPinchEnd(e.scale, item)} onZoomUpdate={onResizeForIndex(itemIndex)} ref={el => { if (el) resumableZoomRef.current[itemIndex] = el; }} /> ); } const style = StyleSheet.absoluteFillObject; return ( item.id} renderItem={renderItem} onBeforeSnapToItem={setGalleryIndex} onSnapToItem={setGalleryIndex} /> {photos.length > 1 && ( )} ); } ```
Photo Component ```ts export const Photo = forwardRef( ( { item, panEnabled = false, onLoadEnd, onZoomUpdate, ...zoomProps }, ref, ) => { const resumableZoomRef = useRef(null); useImperativeHandle, Partial>( ref, () => ({ reset: (...args) => resumableZoomRef.current?.reset(...args), }), [], ); const { isLandscapeOrientation } = useDeviceOrientation(); const { height, width } = useWindowDimensions(); const { resolution, isFetching } = useImageResolution({ uri: item.uri }); function onUpdateHandler({ scale }: CommonZoomState) { 'worklet'; runOnJS(onZoomUpdate)(scale); // Used to listen to zoom changes } if (isFetching || resolution === undefined) { return ( ); } const imageStyle = getAspectRatioSize({ aspectRatio: resolution.width / resolution.height, width: isLandscapeOrientation() ? undefined : width, // Set width and height based on device orientation height: isLandscapeOrientation() ? height : undefined, }); return ( ); }, ); ```

SnapCarousel uses ScrollView with horizontal scroll and snap enabled, but I can't pass an Animated.ScrollView. I can't pinch in any direction in Android because some of those enable swiping on ScrollView.

https://github.com/user-attachments/assets/30e40002-ad61-40fd-aa9c-a0c041b5e6b9

I tried wrapping SnapCarousel with a GestureDetector and configuring a way to disable pinch only on ScrollView. I also tried negating ScrollView to be the move responder (probably incorrectly). I tried adding disableScrollViewPanResponder in SnapCarousel and tried to detect more than two touch points to disable the scroll...

None of this works. I don't have ideas to fix this issue.

Glazzes commented 2 weeks ago

The lib you guys are using does not let any room for a work around, gestures and scroll are by nature conflicting behaviors and whichever triggers first negates the other.

Talking of ResumableZoom, I designed it with scroll-able usage in mind (Flatlist) however it requires the developer to create it's own scroll logic which in itself is not only painful but a time consuming task, if you're brave enough you could attempt such task by using onOverPanning property, this of course breaks the convenience of having a scroll library if you have to write scroll logic yourself.

My best advise is to negotiate again and let know however takes the final decision that you can have scroll or zoom but not both as the zoom capabilities need to be tightly integrated with the scroll-able component itself in such a way they do not collide with each other.

lucianomlima commented 1 week ago

Thanks. That was my suspicion and this library is no longer maintained but has been used in the app for a while. I found another one that uses gesture-handler and reanimated instead of ScrollView. What would be the best approach in your opinion, switching to one like the one I mentioned above or using your library's gallery together with ResumableZoom? Basically I just need to have the 4 gestures working together: double tap, pinch both to zoom, pan when zoom applied, and swipe to change image when zoom is not applied. Also, working fine in all orientations.

Glazzes commented 1 week ago

If you're speaking of react-native-reanimated-carousel I've never tested myself, all I know is the most nested gestures have the biggest priority, I think you would not be able to scroll because of the pan gesture attached to ResumableZoom, this is only a guess based of experience working with GH, however you're free to give a try.

Talking of the gallery shipped with the library this one does not require ResumableZoom, all gestures are already part of the gallery so you just need to pass your images (Not wrapped by any zoom component), all your items share a single instance of ResumableZoom this made for performance reasons, when I mean zoom and scroll need to be tightly integrated this is what I meant because the zoom component needs to drive the scrolling.

The only problem I think you may encounter is the orientation thing, so try it out and let me know.