dohooo / react-native-reanimated-carousel

🎠 React Native swiper/carousel component, fully implemented using reanimated v2, support to iOS/Android/Web. (Swiper/Carousel)
https://react-native-reanimated-carousel.vercel.app
MIT License
2.73k stars 317 forks source link

Gesture swipe on images with desktop? #685

Open shawnmclean opened 3 hours ago

shawnmclean commented 3 hours ago

I'm looking at your examples, especially the normal one here: https://reanimated-carousel.dev/Examples/normal

Repro:

When you do a long press on the item and it changes to an image, how can we get it to swipe left/right when using the mouse on an image?

It works on mobile but desktop, it tries to drag the image itself.

dosubot[bot] commented 3 hours ago

To enable swipe left/right functionality with the mouse on an image using react-native-reanimated-carousel on desktop, without dragging the image itself, you can use the onConfigurePanGesture prop to disable the default pan gesture and then implement a custom gesture handler for mouse events. Here is how you can modify the provided code:

  1. Disable the default pan gesture by setting enabled(false) in onConfigurePanGesture.
  2. Add a custom gesture handler using react-native-gesture-handler to detect mouse swipes.

Here is the modified code:

import * as React from "react";
import type { ICarouselInstance } from "react-native-reanimated-carousel";
import Carousel from "react-native-reanimated-carousel";
import { SafeAreaView } from "react-native-safe-area-context";

import { SBItem } from "../../components/SBItem";
import { window } from "../../constants";
import { Button, Image, ImageSourcePropType, ViewStyle, useWindowDimensions } from "react-native";
import Animated, { Easing, Extrapolate, FadeIn, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDecay, withSpring, withTiming } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import * as Haptics from 'expo-haptics';
import { getImages } from "./images";

const PAGE_WIDTH = window.width;
const data = getImages().slice(0, 68);

function Index() {
  const windowWidth = useWindowDimensions().width;
  const scrollOffsetValue = useSharedValue<number>(0);
  const ref = React.useRef<ICarouselInstance>(null);

  const baseOptions = {
    vertical: false,
    width: windowWidth,
    height: PAGE_WIDTH / 2,
  } as const

  const gesture = React.useMemo(() => Gesture
    .Pan()
    .onUpdate((event) => {
      if (event.translationX > 0) {
        ref.current?.scrollTo({ index: Math.max(0, ref.current?.getCurrentIndex() - 1), animated: true });
      } else {
        ref.current?.scrollTo({ index: Math.min(data.length - 1, ref.current?.getCurrentIndex() + 1), animated: true });
      }
    }),
    [ref]
  );

  return (
    <SafeAreaView
      edges={["bottom"]}
      style={{
        flex: 1,
        backgroundColor: 'black',
        justifyContent: "center",
      }}
    >
      <GestureDetector gesture={gesture}>
        <Carousel
          {...baseOptions}
          loop={false}
          enabled={false} // Disable default pan gesture
          ref={ref}
          defaultScrollOffsetValue={scrollOffsetValue}
          testID={"xxx"}
          style={{ width: "100%" }}
          autoPlay={false}
          autoPlayInterval={1000}
          data={data}
          onConfigurePanGesture={g => g.enabled(false)}
          pagingEnabled
          onSnapToItem={index => console.log("current index:", index)}
          windowSize={2}
          renderItem={({ index, item }) => {
            return <Animated.View
              key={index}
              style={{ flex: 1 }}
            >
              <SBItem
                showIndex={false}
                img={item}
              />
            </Animated.View>
          }}
        />
      </GestureDetector>
      <ThumbnailPagination
        style={{ marginVertical: 9 }}
        onIndexChange={(index) => {
          ref.current?.scrollTo({ index, animated: false })
          Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
        }}
      />
    </SafeAreaView>
  );
}

const ThumbnailPagination: React.FC<{
  style?: ViewStyle
  onIndexChange?: (index: number) => void
}> = ({ style, onIndexChange }) => {
  const [_containerWidth, setContainerWidth] = React.useState<number>(0);
  const inactiveWidth = 30;
  const activeWidth = inactiveWidth * 2;
  const itemGap = 5;
  const totalWidth = inactiveWidth * (data.length - 1) + activeWidth + itemGap * (data.length - 1);
  const swipeProgress = useSharedValue<number>(0);
  const activeIndex = useSharedValue<number>(0);

  const containerWidth = React.useMemo(() => {
    if (totalWidth < _containerWidth) {
      return totalWidth
    }

    return _containerWidth
  }, [
    _containerWidth,
    totalWidth,
  ])

  const gesture = React.useMemo(() => Gesture
    .Pan()
    .onUpdate((event) => {
      swipeProgress.value = Math.min(
        Math.max(event.x, 0),
        containerWidth
      )
    }),
    [
      activeWidth,
      inactiveWidth,
      containerWidth,
    ]
  )

  const animStyles = useAnimatedStyle(() => {
    if (containerWidth <= 0) {
      return {}
    }

    const isOverScroll = totalWidth > containerWidth

    if (!isOverScroll) {
      return {
        transform: [
          {
            translateX: 0
          }
        ],
      }
    }

    return {
      transform: [
        {
          translateX: -interpolate(
            swipeProgress.value,
            [0, containerWidth],
            [0, totalWidth - containerWidth],
            Extrapolate.CLAMP
          )
        }
      ],
    }
  }, [
    containerWidth,
    totalWidth,
    containerWidth
  ])

  useAnimatedReaction(
    () => activeIndex.value,
    (activeIndex) => onIndexChange && runOnJS(onIndexChange)(activeIndex),
    [onIndexChange]
  )

  return <GestureDetector gesture={gesture}>
    <Animated.View style={{ width: '100%', overflow: "hidden" }}>
      <Animated.View style={[{ flexDirection: "row" }, style, animStyles]} onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
        {
          containerWidth > 0 && data.map((item, index) => {
            return <ThumbnailPaginationItem
              key={index}
              source={item}
              totalItems={data.length}
              swipeProgress={swipeProgress}
              containerWidth={containerWidth}
              activeIndex={activeIndex}
              activeWidth={activeWidth}
              itemGap={itemGap}
              inactiveWidth={inactiveWidth}
              totalWidth={totalWidth}
              index={index}
              style={{ marginRight: itemGap }}
              onSwipe={() => {
                console.log(`${item} swiped`)
              }}
            />
          })
        }
      </Animated.View>
    </Animated.View>
  </GestureDetector >
}

const ThumbnailPaginationItem: React.FC<{
  source: ImageSourcePropType;
  containerWidth: number;
  totalItems: number;
  activeIndex: Animated.SharedValue<number>;
  swipeProgress: Animated.SharedValue<number>;
  activeWidth: number;
  totalWidth: number;
  inactiveWidth: number;
  itemGap: number;
  index: number;
  onSwipe?: () => void;
  style?: ViewStyle
}> = ({
  source,
  containerWidth,
  totalItems,
  swipeProgress,
  index,
  itemGap = 0,
  activeIndex,
  activeWidth,
  totalWidth,
  inactiveWidth,
  style
}) => {
    const isActive = useSharedValue(0);

    useAnimatedReaction(
      () => {
        const onTheRight = index >= activeIndex.value
        const extraWidth = onTheRight ? activeWidth - inactiveWidth : 0
        const inputRange = [
          index * (inactiveWidth + itemGap) + (index === activeIndex.value ? 0 : extraWidth) - 0.1,
          index * (inactiveWidth + itemGap) + (index === activeIndex.value ? 0 : extraWidth),
          (index + 1) * (inactiveWidth + itemGap) + extraWidth,
          (index + 1) * (inactiveWidth + itemGap) + extraWidth + 0.1,
        ]

        return interpolate(
          swipeProgress.value / containerWidth * totalWidth,
          inputRange,
          [
            0,
            1,
            1,
            0
          ],
          Extrapolate.CLAMP
        )

      },
      (_isActiveAnimVal) => {
        isActive.value = _isActiveAnimVal
      },
      [
        containerWidth,
        totalItems,
        index,
        activeIndex,
        activeWidth,
        inactiveWidth,
        itemGap,
      ]
    )

    useAnimatedReaction(
      () => {
        return isActive.value
      },
      (isActiveVal) => {
        if (isActiveVal === 1) {
          activeIndex.value = index
        }
      },
      []
    )

    const animStyles = useAnimatedStyle(() => {
      const widthAnimVal = interpolate(
        isActive.value,
        [
          0, 1, 1, 0
        ],
        [
          inactiveWidth,
          activeWidth,
          activeWidth,
          inactiveWidth
        ],
        Extrapolate.CLAMP
      )

      return {
        width: withTiming(widthAnimVal, { duration: 100, easing: Easing.bounce }),
        height: 30,
        borderRadius: 5,
        overflow: "hidden",
      }
    }, [
      isActive,
      activeWidth,
      inactiveWidth,
    ])

    return <Animated.View style={[animStyles, style]}>
      <Image source={source} style={{ width: '100%', height: "100%" }} />
    </Animated.View>
  }

export default Index;

This code sets up a custom pan gesture to handle mouse swipes and scrolls the carousel accordingly [1][2].

To continue talking to Dosu, mention @dosu.