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}!`,
};
});
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} />;
}}
/>
);
};
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}
/>
);
}
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}
/>
);
}
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
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.
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}
/>
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.
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.
You can try it out yourself: https://snack.expo.io/@hrastnik/carousel
Top comments (4)
An awesome article! I'm shocked, it has 2 years and nobody gave you any kudos for this!
So good. I was looking for a way to implement screen slider and your article helped a lot! Thank you!
just starting out with reactnative and this guide really helped a lot. thank you so very much
Its fab