Building a carousel with drag and drop in React Native

An exploration into ways to achieve an animated horizontal scrollview of data, allowing for vertical scrolling and drag-and-drop

ยท

9 min read

Featured on Hashnode

Aim

For a recent project at work, we needed to create a carousel of large cards, each containing a list of items. The carousel would need to be nicely animated when a user swiped horizontally. We also needed the card content to be vertically scrollable, and most importantly, the items in each card's list needed to be easily rearranged using drag and drop.

The business requirements were to get this feature out in a short time frame, so I looked to the community to see if any packages could do at least some of the heavy lifting for us.

Approach no. 1

Can we use existing libraries?

Having researched a few different packages, these were the 2 that stood head and shoulders above the rest in my opinion.

For the carousel:

react-native-reanimated-carousel has some great features:

  • Built with Reanimated 2

  • Typescript

  • Parallax horizontal scrolling option out-of-the-box

For the drag and drop:

react-native-draggable-flatlist is a fantastic library that has recently been rewritten to use Reanimated 2 and react-native-gesture-handler. I love the way this library has been built - the code is clean and easy to read, and I like the way the animation code has been organised using Context providers to store animated values and refs. It also has some very thoughtful props that make it a joy to use.

Both of these libraries worked brilliantly on their own, but I could not get them to play nicely together. As soon as the drag-and-drop was in place, the swipe gesture of the carousel no longer worked. I thought this was likely due to conflicting gesture handlers. (My colleague found a workaround for this later - more to follow)

I spotted the simultaneous-handlers prop in the drag-and-drop library which looked like it would do the trick, but there was no easy way to get a reference from the carousel's gesture handler to pass it along.

Approach no.2

I decided that getting the 2 libraries to play together was going to take longer than I had thought - and I wasn't entirely sure it would even work. So, I decided that it would be simpler to build the carousel from scratch. From playing with the drag-and-drop library, I knew I could utilise the very handy simultaneous-handlers prop to pass a reference from my carousel gesture handler to the drag-and-drop - hopefully allowing both gesture handlers to work together.

One way to build a carousel would be to use Reanimated <Animated.ScrollView> with the useAnimatedScrollHandler - (see documentation). I tried this first but I had the same issue where the drag and drop would disable the scrolling. Instead, I decided to use the new Gesture Handler API from react-native-gesture-handler to build the scrollView.

This is the result of the code below. Please note that the drag-and-drop element including the styling is simply taken from the library's example code: react-native-draggable-flatlist

  • Set up a new expo app with the following libraries - note: reanimated requires some extra setup, and use the expo docs for the gesture handler installation to ensure compatibility

    • react-native-gesture-handler

    • react-native-reanimated

    • react-native-redash - this library is used to help with the carousel's snap points

    • react-native-draggable-flatlist

// ScrollableView.tsx

import {StyleSheet} from 'react-native';
import React from 'react';
import Slide from './Slide';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useCarouselValues} from './context/CarouselContext';
import {snapPoint} from 'react-native-redash';

const ScrollableView = () => {
  const {
    panRef,
    minimumTouchCheck,
    translationX,
    prevSnap,
    currentSnap,
    nextSnap,
    swipe,
    sideSpace,
    slideWidth,
    slideData,
  } = useCarouselValues(); // storing a lot of the functionality in the carouselContext to halp kep the component cleaner, and the carousel more reusable

  const offsetX = useSharedValue<number>(0); // the internal context of the gesture

  const panGesture = Gesture.Pan()
    .onBegin(() => {
      offsetX.value = translationX.value;
    })
    .onUpdate((e) => {
      if (minimumTouchCheck(translationX.value, e.translationX)) {
        translationX.value = offsetX.value + e.translationX;
      }
    })
    .onEnd((e) => {
      const snapPoints = [prevSnap.value, currentSnap.value, nextSnap.value];
      const dest = snapPoint(translationX.value, e.velocityX, snapPoints);
      const direction = e.velocityX < 0 ? 'left' : 'right';

      swipe(translationX, dest, 5, direction);
    })
    .withRef(panRef); // IMPORTANT - this is where the pan gesture ref is set

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [{translateX: translationX.value}],
    };
  });

  return (
    <GestureDetector gesture={panGesture}>
      <Animated.View
        style={[
          styles.view,
          {
            marginStart: sideSpace,
          },
          animatedStyle,
        ]}
      >
        {slideData.map((item) => (
          <Slide width={slideWidth} key={item} index={item} />
        ))}
      </Animated.View>
    </GestureDetector>
  );
};

export default ScrollableView;

const styles = StyleSheet.create({
  view: {
    flex: 1,
    alignContent: 'center',
    flexDirection: 'row',
  },
});

In order to pass the ref from the carousel to the draggable flatList, I made a context to store it, so that it could be accessed from any child component.

it also made sense to store the carousel's animation logic there so that we could potentially reuse it in other places. For example, by storing the animated values and swipe functions in the context, we could use them to control the carousel via another child component - like some controller buttons to allow for a more accessible, non-swipe option.

import React, {useContext} from 'react';
import {useMemo, useRef} from 'react';
import {Dimensions} from 'react-native';
import {GestureType} from 'react-native-gesture-handler/lib/typescript/handlers/gestures/gesture';
import Animated, {
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';

type CarouselContextValue = {
  panRef: React.MutableRefObject<GestureType | undefined>; // the panGesture Handler ref from the carousel, needed so we can pass to the drag and drop simultaneousHandlers
  swipe: (
    translateX: Animated.SharedValue<number>,
    dest: number,
    velocity: number,
    direction: 'left' | 'right'
  ) => void;
  minimumTouchCheck: (prev: number, curr: number) => boolean;
  prevSnap: Readonly<Animated.SharedValue<number>>;
  currentSnap: Readonly<Animated.SharedValue<number>>;
  nextSnap: Readonly<Animated.SharedValue<number>>;
  translationX: Animated.SharedValue<number>;
  sideSpace: number;
  slideWidth: number;
  slideData: number[];
};

const CarouselContext = React.createContext<CarouselContextValue | undefined>(
  undefined
);

export default function CarouselContextProvider({
  children,
  data,
}: {
  children: React.ReactNode;
  data: number[];
}) {
  const panRef = useRef<GestureType | undefined>(undefined); // Store the ref to the carousel's pan gesture so we can pass it to the drag and drop's simultaneous handlers

  const {width} = Dimensions.get('window');
  const sideSpace = 30; // The amount the next/prev slide peeps on each side. Could set this as a prop on the carousel instead
  const slideWidth = width - sideSpace * 2;

  const activeIndex = useSharedValue<number>(0); // index of the currently centered slide
  const translationX = useSharedValue<number>(0); // The current translation of the whole carousel

  const currentSnap = useDerivedValue(
    () => -(activeIndex.value * slideWidth),
    [activeIndex.value]
  );

  const prevSnap = useDerivedValue(
    () => -((activeIndex.value - 1) * slideWidth),
    [activeIndex.value]
  );

  const nextSnap = useDerivedValue(
    () => -((activeIndex.value + 1) * slideWidth),
    [activeIndex.value]
  );

  const MIN_TOUCH = 40;

  const minimumTouchCheck = (prev: number, curr: number) => {
    'worklet';
    return prev > curr + MIN_TOUCH || prev < curr - MIN_TOUCH;
  };

  const swipe = (
    translateX: Animated.SharedValue<number>,
    dest: number,
    velocity: number,
    direction: 'left' | 'right'
  ) => {
    'worklet';

    //Prevents carousel swiping to the left of the first slide
    if (activeIndex.value <= 0 && direction === 'right') {
      dest = 0;
    }

    //Prevents carousel swiping beyond the last slide
    if (activeIndex.value >= data.length - 1 && direction === 'left') {
      dest = -(data.length - 1) * slideWidth;
    }

    translateX.value = withSpring(
      dest,
      {
        mass: 1,
        velocity,
        damping: 20,
      },
      () => {
        // set the active index according to the final destination
        activeIndex.value = Math.ceil(Math.abs(dest / slideWidth));
      }
    );
  };

  const values = useMemo(
    () => ({
      panRef,
      swipe,
      minimumTouchCheck,
      prevSnap,
      currentSnap,
      nextSnap,
      translationX,
      sideSpace,
      slideWidth,
      activeIndex,
      slideData: data,
    }),
    [
      panRef,
      swipe,
      minimumTouchCheck,
      prevSnap,
      currentSnap,
      nextSnap,
      translationX,
      sideSpace,
      slideWidth,
      activeIndex,
      data,
    ]
  );

  return (
    <CarouselContext.Provider value={values}>
      {children}
    </CarouselContext.Provider>
  );
}

export function useCarouselValues() {
  const value = useContext(CarouselContext);
  if (!value) {
    throw new Error(
      'useCarouselValues must be called from within a CarouselContext.Provider!'
    );
  }
  return value as CarouselContextValue;
}

We then use wrap any child components that need access to the context in the CarouselContextProvider, passing the data for the carousel as a prop.

// App.tsx

import React from 'react';
import {SafeAreaView, StyleSheet} from 'react-native';
import CarouselContextProvider from './src/carousel/context/CarouselContext';
import ScrollableView from './src/carousel/ScrollableView';

export default function App() {
  const slideData = [...Array(5).keys()]; // hard coded for this example.

  return (
    <SafeAreaView style={styles.container}>
      <CarouselContextProvider data={slideData}>
        <ScrollableView />
      </CarouselContextProvider>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

Features

Disable sideways scrolling whilst using the drag and drop

It is a little confusing to have the carousel moving from side to side whilst you are using the drag-and-drop. The drag-and-drop library gives us the isActive prop on the renderItem component, so we know when the items are moving - but I could not use this to disable the carousel as the events did not fire in time to disable it. I found that setting a minimum distance on the swipe of around 40px was enough to stop the carousel from moving whilst using the drag and drop for the most part - perhaps there is a better solution though.

Alternative Approach

Use a regular horizontal FlatList or ScrollView

In the end, for our project and in the timeframe we had, we felt there were performance issues with the hand-made carousel once we had loaded in all the data we needed, due to the underlying map(). We decided to just use a regular horizontal flatList to gain some of the built-in performance benefits of the virtualised list.

One big issue we found with this approach was the lack of a gesture handler ref to pass to the simultaneousHandler prop. My colleague, Kris found a nifty workaround - by adding this to the draggable flatlist you do not need to use the simultaneousHandlers, and the swipe gesture for the flatlist will still work while swiping on the draggable item: activationDistance={Platform.OS === 'android' ? 10 : 5} (we found that Android required a larger distance than ios). Even with this workaround, it is perhaps not quite as responsive as using the simultaneousHandlers.

For our timeframe, this turned out to be the safest solution, but I think it has some disadvantages compared to the hand-made map option. For example:

  • you are limited to the flatlist's snapTo animation - which I felt was not very configurable.

  • By doing away with the gesture handler, you are more limited in the other animations you can add to the flatlist/carousel items.

With more time, I would have liked to have stuck with the approach using the gesture handler, and looked into how we could improve the performance of the map, perhaps by loading only what we needed for the current slide - creating our own kind of virtualised list.

Notes on accessibility

Drag and drop is notoriously tricky in terms of accessibility, particularly on mobile. In terms of labelling the individual pressable elements, we do have the option of adding some accessibilityActions. Perhaps you could have some actions like 'move item up 1' and 'move item down 1' and add some accessibility labelling to communicate to the user how to achieve this.

Another option could be to add an alternative, more accessible way to reorder - perhaps using buttons instead of drag and drop. This would also be useful should the user be trying to navigate the app using a keyboard.

In our app at work, we implemented an accessible re-ordering option that allowed the user to press arrows to re-order the items.

For this experimental example, accessibility has not been implemented and would therefore require some serious re-working before being production-ready.

References and credits

ย