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.81k stars 324 forks source link

Feature Request: Accommodate "disabled" items in the carousel #661

Open seanmaisch opened 1 month ago

seanmaisch commented 1 month ago

Is your feature request related to a problem? Please describe.

Although this may be an odd scenario for some image carousels, it is rather frustrating when trying to use the carousel in the context of some selectable "content" that may need to be disabled. An example of this would be a list of products that are in store, but one or more of them are sold out. I would like to continue to display those options, but not allow the carousel to land on them as an index, and instead advance forward or backward towards the next non-disabled option.

Describe the solution you'd like

For my use case, I would like the ability prevent the user from "landing" on a carousel item that is disabled. There doesn't have to be any visual distinction included in this update, but allowing individual items to be set as disabled would have the scroll gesture advance to the next available option (if advancing "forward"), or move backwards towards the last available option.

There would need to be additional checks included as well:

  1. (If loop={false}) If the last or first items in the array are disabled, do not try to advance further than them, as it would attempt to reach an index that does not exist.
  2. If more than one disabled items are next to each other, it would need to skip over the consecutive disabled items until it reaches the next non-disabled item.

Describe alternatives you've considered

I've tried utilizing an approach like this, however it results in a jerky, awkward animation, due to the fact it has to arrive at the disabled index and check against it before advancing to the next non-disabled option.

// Function to find the next valid index that isn't disabled
const findNextValidIndex = (index: number, direction: 1 | -1) => {
  const len = items.length;
  let nextIndex = index;

  // Loop forward or backward depending on the direction
  while (nextIndex >= 0 && nextIndex < len && items[nextIndex].disabled) {
    nextIndex += direction;
  }

  // Ensure nextIndex is within bounds
  if (nextIndex < 0 || nextIndex >= len) {
    return index; // If no valid index, return the current index
  }
  return nextIndex;
};

const onSnapToItem = (index: number) => {
  let nextIndex = index;

  // If the current item is disabled, find the next valid index forward or backward
  if (items[index].disabled) {
    const direction = index < activeIndex ? -1 : 1;
    nextIndex = findNextValidIndex(index, direction);

    // Scroll to the next valid index with animation
    ref.current?.scrollTo({
      index: nextIndex,
      animated: true,
    });
  }

    handleFilterChange(nextIndex);
  }
};

Thanks, and if you know of a way to achieve this cleanly, I'd be open to implementing your solution(s). I haven't found a decent way to do it myself.

dosubot[bot] commented 1 month ago

Hey @seanmaisch 👋, I'm here to help you out with any bugs, questions, or contributions you have. Let's tackle this carousel issue together!

To accommodate "disabled" items in the carousel and prevent landing on them, you can modify the next and prev functions in the carouselController to skip disabled items. Here is an example of how you can achieve this:

  1. Add a disabled property to your data items to indicate which items are disabled.
  2. Modify the next and prev functions to skip disabled items.

Here is the modified code:

const Carousel = React.forwardRef<ICarouselInstance, TCarouselProps<any>>(
  (_props, ref) => {
    const props = useInitProps(_props);

    const {
      data,
      dataLength,
      loop,
      onSnapToItem,
      onScrollEnd,
      onScrollStart,
      scrollAnimationDuration,
      withAnimation,
      defaultIndex,
    } = props;

    const commonVariables = useCommonVariables(props);
    const { size, handlerOffset } = commonVariables;

    const offsetX = useDerivedValue(() => {
      const totalSize = size * dataLength;
      const x = handlerOffset.value % totalSize;

      if (!loop) return handlerOffset.value;

      return isNaN(x) ? 0 : x;
    }, [loop, size, dataLength]);

    const carouselController = useCarouselController({
      loop,
      size,
      dataLength,
      handlerOffset,
      withAnimation,
      defaultIndex,
      duration: scrollAnimationDuration,
      onScrollEnd: () => runOnJS(_onScrollEnd)(),
      onScrollStart: () => !!onScrollStart && runOnJS(onScrollStart)(),
    });

    const { getCurrentIndex } = carouselController;

    const next = React.useCallback(() => {
      let currentIndex = getCurrentIndex();
      let nextIndex = (currentIndex + 1) % dataLength;

      while (data[nextIndex].disabled) {
        nextIndex = (nextIndex + 1) % dataLength;
        if (nextIndex === currentIndex) break; // Prevent infinite loop
      }

      carouselController.scrollTo({ index: nextIndex, animated: true });
    }, [data, dataLength, getCurrentIndex, carouselController]);

    const prev = React.useCallback(() => {
      let currentIndex = getCurrentIndex();
      let prevIndex = (currentIndex - 1 + dataLength) % dataLength;

      while (data[prevIndex].disabled) {
        prevIndex = (prevIndex - 1 + dataLength) % dataLength;
        if (prevIndex === currentIndex) break; // Prevent infinite loop
      }

      carouselController.scrollTo({ index: prevIndex, animated: true });
    }, [data, dataLength, getCurrentIndex, carouselController]);

    const _onScrollEnd = React.useCallback(() => {
      const _sharedIndex = Math.round(getSharedIndex());

      const realIndex = computedRealIndexWithAutoFillData({
        index: _sharedIndex,
        dataLength: rawDataLength,
        loop,
      });

      if (onSnapToItem) onSnapToItem(realIndex);

      if (onScrollEnd) onScrollEnd(realIndex);
    }, [
      loop,
      rawDataLength,
      getSharedIndex,
      onSnapToItem,
      onScrollEnd,
    ]);

    React.useImperativeHandle(
      ref,
      () => ({
        next,
        prev,
        getCurrentIndex,
        scrollTo: carouselController.scrollTo,
      }),
      [getCurrentIndex, next, prev, carouselController.scrollTo],
    );

    return (
      <GestureHandlerRootView>
        <ScrollViewGesture
          size={size}
          translation={handlerOffset}
          onScrollStart={scrollViewGestureOnScrollStart}
          onScrollEnd={scrollViewGestureOnScrollEnd}
        >
          <ItemRenderer
            data={data}
            dataLength={dataLength}
            loop={loop}
            size={size}
            offsetX={offsetX}
            handlerOffset={handlerOffset}
            renderItem={renderItem}
          />
        </ScrollViewGesture>
      </GestureHandlerRootView>
    );
  },
);

export default Carousel;

In this implementation, the next and prev functions are modified to skip over disabled items by checking the disabled property of each item in the data array. This ensures that the carousel will not land on disabled items and will handle edge cases like consecutive disabled items and array bounds. The scrollTo method in the carouselController is used to ensure smooth animation when navigating to the next or previous item [1][2].

To continue talking to Dosu, mention @dosu.