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

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

Open
lucianomlima opened this issue Oct 11, 2024 · 5 comments

Comments

@lucianomlima
Copy link

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

Glazzes commented Oct 11, 2024

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

@lucianomlima
Copy link
Author

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
function Gallery({ index, photos, onLoadEnd, gallerySync }) {
  const [currentIndex, setCurrentIndex] = useState(index);
  const [isZoomEnabled, setZoomEnabled] = useState(false);
  const resumableZoomRef = useRef<ResumableZoomType[]>([]);
  const timeoutID = useRef<NodeJS.Timeout>();

  const navigation = useNavigation<StackNavigationProp<ParamListBase>>();

  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 (
      <Photo
        item={item}
        panEnabled={isZoomEnabled}
        pinchEnabled={enablePartnerGalleryZoom}
        tapsEnabled={enablePartnerGalleryZoom}
        onLoadEnd={onLoadEnd}
        onPanEnd={() => onPanEnd(item)}
        onPinchEnd={e => onPinchEnd(e.scale, item)}
        onZoomUpdate={onResizeForIndex(itemIndex)}
        ref={el => {
          if (el) resumableZoomRef.current[itemIndex] = el;
        }}
      />
    );
  }

  const style = StyleSheet.absoluteFillObject;

  return (
    <Box alignItems="center" justifyContent="center" style={style}>
      <SnapCarousel
        enableSnap
        enableMomentum
        horizontal
        useScrollView
        scrollEnabled={!isZoomEnabled}
        initialNumToRender={1}
        maxToRenderPerBatch={2}
        sliderWidth={width}
        itemWidth={width}
        data={photos}
        firstItem={currentIndex}
        keyExtractor={(item: PhotoItem) => item.id}
        renderItem={renderItem}
        onBeforeSnapToItem={setGalleryIndex}
        onSnapToItem={setGalleryIndex}
      />
      {photos.length > 1 && (
        <Dots length={photos.length} current={currentIndex} />
      )}
    </Box>
  );
}
Photo Component
export const Photo = forwardRef<ResumableZoomType, PhotoProps>(
  (
    { item, panEnabled = false, onLoadEnd, onZoomUpdate, ...zoomProps },
    ref,
  ) => {
    const resumableZoomRef = useRef<ResumableZoomType>(null);

    useImperativeHandle<Partial<ResumableZoomType>, Partial<ResumableZoomType>>(
      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<number>) {
      'worklet';

      runOnJS(onZoomUpdate)(scale); // Used to listen to zoom changes
    }

    if (isFetching || resolution === undefined) {
      return (
        <Loading
          testID="gallery-photo-loading"
          hardwareAccelerationAndroid
          autoPlay
          loop
        />
      );
    }

    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 (
      <Box flex={1} testID="gallery-image-container">
        <ResumableZoom
          extendGestures
          panEnabled={panEnabled}
          maxScale={3}
          scaleMode="clamp"
          onUpdate={onUpdateHandler}
          ref={resumableZoomRef}
          {...zoomProps}>
          <Image
            accessibilityRole="image"
            resizeMethod="scale"
            onLoadEnd={onLoadEnd}
            source={{ uri: item.uri }}
            style={imageStyle}
          />
        </ResumableZoom>
      </Box>
    );
  },
);

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.

android-gesture-conflict.mp4

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

Glazzes commented Oct 12, 2024

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

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

Glazzes commented Oct 13, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants