DEV Community

loading...
Cover image for Let's create a carousel in React Native
Lloyds design

React Native Carousel Let's create a carousel in React Native

hrastnik profile image Mateo Hrastnik ・7 min read

Sooner or later you're going to need a carousel in one of your projects. Perhaps you want to display a list of images, maybe an introductory tour of your app, or maybe you want your app to have a couple of swipeable screens. Whatever your use case may be, this article can probably help you.

Let's get started. The base of our carousel will be a simple FlatList component. The reason for this is simple - it's based on the ScrollView component that will enable us to swipe the slides, plus, it implements VirtualizedList which we can use for optimization when there are lots of images or performance heavy UI elements in our slides.

First, let's create some dummy data. We'll use Lorem Picsum to get random images, and we'll create random data for 30 slides for our carousel.

const { width: windowWidth, height: windowHeight } = Dimensions.get("window");

const slideList = Array.from({ length: 30 }).map((_, i) => {
  return {
    id: i,
    image: `https://picsum.photos/1440/2842?random=${i}`,
    title: `This is the title! ${i + 1}`,
    subtitle: `This is the subtitle ${i + 1}!`,
  };
});
Enter fullscreen mode Exit fullscreen mode

Note that we have to add the query parameter random=${i} to get a random image for each slide. Otherwise, React Native would cache the first image and use it in place of every image in our carousel.

Next, we'll create a FlatList and pass our slideList to the data prop. We'll also pass it the style prop with flex: 1 so it covers the whole screen. Lastly, we have to define the way our slides are going to look. This is done using the renderItem prop.
We'll create a Slide component and use it in the renderItem function.

function Slide({ data }) {
  return (
    <View
      style={{
        height: windowHeight,
        width: windowWidth,
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <Image
        source={{ uri: data.image }}
        style={{ width: windowWidth * 0.9, height: windowHeight * 0.9 }}
      ></Image>
      <Text style={{ fontSize: 24 }}>{data.title}</Text>
      <Text style={{ fontSize: 18 }}>{data.subtitle}</Text>
    </View>
  );
}

function Carousel() {
  return (
    <FlatList
      data={slideList}
      style={{ flex: 1 }}
      renderItem={({ item }) => {
        return <Slide data={item} />;
      }}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Vertical carousel, no snap points

If we save now, we'll see our slides, but the scroll behavior is not as we want it. We have to make the ScrollView snap to the beginning of every slide. The easiest way to achieve this is to add the pagingEnabled={true} prop to the FlatList.

Another thing - our carousel is currently vertical - it scrolls up and down. Most carousels are horizontal, so let's change the orientation, however, keep in mind that if you need to build a vertical carousel it's possible and only requires a couple of changes.

So let's add the horizontal={true} prop to our FlatList to make it scroll left and right, and while we're at it, let's add the showsHorizontalScrollIndicator={false} prop to hide the scroll indicator.

function Carousel() {
  return (
    <FlatList
      data={slideList}
      style={{ flex: 1 }}
      renderItem={({ item }) => {
        return <Slide data={item} />;
      }}
      pagingEnabled
      horizontal
      showsHorizontalScrollIndicator={false}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Horizontal carousel, with snap points

This looks great, but there's one important thing we're missing. We're probably going to need the index of the active slide. For example, if we're building a carousel for the application introductory tour, we maybe want to have a "Continue" button that gets enabled only when the user reaches the last slide, or if we're building an image gallery, we might want to display a pagination component to let the user know how much images it contains.

I've spent some time optimizing this next part so it might seem a bit complicated. But don't worry, I'll explain everything.

function Carousel() {
  const [index, setIndex] = useState(0);
  const indexRef = useRef(index);
  indexRef.current = index;
  const onScroll = useCallback((event) => {
    const slideSize = event.nativeEvent.layoutMeasurement.width;
    const index = event.nativeEvent.contentOffset.x / slideSize;
    const roundIndex = Math.round(index);

    const distance = Math.abs(roundIndex - index);

    // Prevent one pixel triggering setIndex in the middle
    // of the transition. With this we have to scroll a bit
    // more to trigger the index change.
    const isNoMansLand = 0.4 < distance;

    if (roundIndex !== indexRef.current && !isNoMansLand) {
      setIndex(roundIndex);
    }
  }, []);

  // Use the index
  useEffect(() => {
    console.warn(index);
  }, [index]);

  return (
    <FlatList
      data={slideList}
      style={{ flex: 1 }}
      renderItem={({ item }) => {
        return <Slide data={item} />;
      }}
      pagingEnabled
      horizontal
      showsHorizontalScrollIndicator={false}
      onScroll={onScroll}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

First we define index with useState - this is going to represent the index of the active slide in the carousel. Then we define indexRef - a ref value that is kept in sync with the index variable - when the index changes, so does the value of indexRef.current.

So why are we doing this? The answer is in the next line. The onScroll callback does some calculations with the layoutMeasurement and contentOffset values in order to calculate the current index according to the distance we scrolled. We want to update our index whenever the calculated index changes.

The problem is - if we use the index variable inside onScroll to check whether the calculated index is different from the current index, then we have to put index in the dependency array of useCallback. This in turn means that every time the index changes, the onScroll function changes too, and as it gets passed as a prop to FlatList, it means the list will re-render.

Note that we used layoutMeasurement.width and contentOffset.x to calculate the current index since the carousel is horizontal. If it were vertical, we would have to use height and y offset.

Then there's the logic behind the isNoMansLand variable. This logic prevents the slider to trigger a bunch of setIndex calls when we drag the carousel right in the middle of two slides. Here's what happens when we don't implement this logic - when we're in the middle of two slides, the slightest movement triggers the index change. This can lead to lots of re-renders so it's better to avoid it.

The solution has something to do with this: Schmitt trigger

No Schmitt trigger

Now, what we've built so far is already kinda cool, and it might even be enough for your use case, but there's some hidden performance problems with our implementation that could slow down or even crash your app. This is because it's rendering a whole bunch of slides in advance and it also keeps previous slides in memory. FlatList does this by default to improve perceived performance when we're scrolling through the list fast, but in our case it has negative effects on performance.

I've coded up a simple visualization to show which Slides are mounted and which are not, additionally it highlights our current index. The green dots on the bottom represent the mounted slides, the black ones are unmounted, and the red one is the current active slide.

Horizontal carousel, not optimized

You can notice that the slides are being mounted 10 slides in advance. Additionally, the first 10 slides never get unmounted. This is all a part of FlatList default optimizations that work great for longer lists, but not for our use case.

So let's implement some optimizations. We'll group the optimization props in an object and pass them to FlatList .

  const flatListOptimizationProps = {
    initialNumToRender: 0,
    maxToRenderPerBatch: 1,
    removeClippedSubviews: true,
    scrollEventThrottle: 16,
    windowSize: 2,
    keyExtractor: useCallback(e => e.id, []);
    getItemLayout: useCallback(
      (_, index) => ({
        index,
        length: windowWidth,
        offset: index * windowWidth,
      }),
      []
    ),
  };

  <FlatList
    data={slideList}
    style={{ flex: 1 }}
    renderItem={({ item }) => {
      return <Slide data={item} />;
    }}
    pagingEnabled
    horizontal
    showsHorizontalScrollIndicator={false}
    onScroll={onScroll}
    {...flatListOptimizationProps}
  />
Enter fullscreen mode Exit fullscreen mode

Here's the explanation for what all of this means.

initialNumToRender - This controls how many slides, starting from the first one, will stay rendered at all times. This is useful in lists where we can scroll to top programmatically - in that case we don't want to wait for the first couple of slides to render so FlatList keeps the rendered at all times. We don't need this functionality so it's safe to put 0 here.

maxToRenderPerBatch - This controls how many slides will be rendered per batch. Again this is useful when we have a FlatList with many elements and the user can scroll fast to an are of the FlatList where the data hasn't been loaded yet.

removeClippedSubviews - This removes views that are out of the FlatLists viewport. Android has this set to true by default, and I recommend setting on iOS as well. It can remove Image components from memory and save some resources.

scrollEventThrottle - Controls how many scroll events get triggered while the user drags the carousel. Setting it to 16 means the event will trigger every 16ms. We could probably get away with setting this to a higher number, but 16 seems to work fine.

windowSize - This controls how many slides are mounted up front, and how many slides stay mounted behind the current index.
It actually controls the width of the window which VirtualizedList uses to render items - everything inside the window is rendered, and outside of it is blank. If we set this prop to, for example 2, the window will be twice the width of the FlatList. The pink line in the following vizualization is signifies the window.

Window vizualization

For this carousel example, the value 2 works great, but you can experiment with it if you feel like it.

keyExtractor - React uses this for internal optimizations. Adding and removing slides might break without this. Also, it removes a warning so that's good.

getItemLayout - an optional optimization that allows skipping the measurement of dynamic content if we know the size (height or width) of items ahead of time. In our case the width of the items is always windowWidth. Note that if you want your carousel to be vertical, you have to use windowHeight instead.

In the end we can move the style outside of the component definition and wrap the renderItem function in useCallback to avoid our FlatList re-rendering unnecessarily.

One more thing we can do to further optimize our carousel is wrap our Slide element in React.memo.

That's it! I've added a pagination component and tweaked the styles a bit and here's how the end product looks like.

Final carousel

You can try it out yourself: https://snack.expo.io/@hrastnik/carousel

Discussion (0)

pic
Editor guide