DEV Community

Cover image for React Native Tilt Carousel Animation
Grant Sander
Grant Sander

Posted on

React Native Tilt Carousel Animation

Overview

React Native has a FlatList component that takes a list of items, and then smartly renders an element for each item in the list. At first glance, it seems like this component is just for rendering large lists of elements. It turns out, React Native FlatLists can do a ton of cool stuff! In this post, we'll look at one of those things: building a carousel that has a fancy tilt effect. See the sample below.

Sample of tilt carousel

You can find the source code for this example here.

A Little Setup

Let's start with a little setup. We'll create a list of data that we'll use to render our carousel slides. We'll also use the Dimensions API from React Native to get the width and height of the screen.

import { Dimensions } from "react-native";

// Set up our slides
type Slide = {
  image: any;
  title: string;
  subtitle: string;
};
const SLIDES: Slide[] = [
  {
    image: require("./img/summer.jpg"),
    title: "Summer",
    subtitle: "Warm days, fun nights.",
  },
  {
    image: require("./img/fall.jpg"),
    title: "Fall",
    subtitle: "Sweater weather, baby.",
  },
  {
    image: require("./img/winter.jpg"),
    title: "Winter",
    subtitle: "The season to be jolly.",
  },
  {
    image: require("./img/spring.jpg"),
    title: "Spring",
    subtitle: "April showers, may flowers.",
  },
];

// Utils
const { width, height } = Dimensions.get("window");
Enter fullscreen mode Exit fullscreen mode

Building the Carousel

Now that we have our data setup, let's go ahead and put together the bones of our carousel. This will consist of a FlatList that will take in our SLIDES data, and a custom component CarouselSlide that is used to render each slide of the carousel. There's a styles object that holds styles for the carousel, but let's not worry about those details right now.

/**
 * Tilt Carousel View
 */
export const TiltCarousel: React.FC = () => {
  return (
    <View style={styles.background}>
      <FlatList
        data={SLIDES}
        renderItem={({ item, index }: { item: Slide; index: number }) => (
          <CarouselSlide slide={item} />
        )}
        horizontal
        snapToInterval={width}
        showsHorizontalScrollIndicator={false}
        scrollEventThrottle={16}
        decelerationRate={-1}
        bounces={true}
        keyExtractor={(slide: Slide) => slide.title}
      />
    </View>
  );
};

/**
 * Slide item
 */
const CarouselSlide: React.FC<{
  slide: Slide;
}> = ({ slide }) => {
  return (
    <View style={styles.cardContainer}>
      <ImageBackground source={slide.image} style={{ flex: 1 }}>
        <Spacer height={0.7 * height} />
        <View style={styles.cardContentContainer}>
          <AppText style={styles.title}>{slide.title}</AppText>
          <AppText style={styles.subtitle}>{slide.subtitle}</AppText>
        </View>
      </ImageBackground>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

There are a few things to note here:

  • We'll use the horizontal prop on the FlatList to make our list render horizontally.
  • We'll set snapToInterval={width} so that the FlatList "snaps" at certain intervals. Importantly: we'll snap to width, the width of the screen. Each carousel slide has a width of width as well, so that we can snap each carousel slide into view.
  • We'll use scrollEventThrottle={16} and decelerationRate={-1} to control the carousel's slide physics.
  • At this point, the SlideCarousel just renders an image background using the slide's image, and some text. I'm using custom AppText and Spacer components that render text and space, accordingly.

At this point, we've got a pretty good start. This is what we have:

Basic version

However, we're going to spice this thing up with some custom animation!

Sprinkling in Some Animation

We're going to give our carousel slides a nice "tilt" effect when they come in and out of view. To do this, we need to do a couple things:

  • Keep track of the user's scroll position in the FlatList.
  • Use this scroll position to animate some transforms on each carousel slide.

Keeping Track of the User's Scroll Position.

To add animation around our FlatList scroll, we need to create and track an Animated value that corresponds to how far the user has scrolled along in the FlatList. React Native has an Animated.Value API for creating an animated value (that we can animate the value of and use in styles to created animated effects). We'll use this animated value to track how far the user has scrolled along in the list. To do this, we'll use the onScroll prop of the FlatList and the Animated.event API. We'll go ahead and pass the scrollX value on to each <CarouselSlide /> element.

export const TiltCarousel: React.FC = () => {
+  const scrollX = React.useRef(new Animated.Value(0)).current;

  return (
    <View style={styles.background}>
      <Animated.FlatList
        data={SLIDES}
        renderItem={({ item, index }: { item: Slide; index: number }) => (
-         <CarouselSlide slide={item} />
+         <CarouselSlide slide={item} scrollX={scrollX} index={index} />
        )}
        horizontal
        snapToInterval={width}
        showsHorizontalScrollIndicator={false}
        scrollEventThrottle={16}
        decelerationRate={-1}
        bounces={true}
        keyExtractor={(slide: Slide) => slide.title}
+       onScroll={Animated.event(
+         [{ nativeEvent: { contentOffset: { x: scrollX } } }],
+         { useNativeDriver: true },
+       )}
      />
    </View>
  );
};

const CarouselSlide: React.FC<{
  slide: Slide;
+ scrollX: Animated.Value;
+ index: number;
-}> = ({ slide }) => {
+}> = ({ slide, scrollX, index }) => {
Enter fullscreen mode Exit fullscreen mode

This change hasn't added any visual changes, but now we have access to scrollX, which keeps track of the user's horizontal scroll position - and we'll use this to animate the carousel slides.

Animate the Carousel Slides

We're going to add a little "tilt" effect to the carousel slides.

Sample of the tilt animation

To do this, we're going to use the value of scrollX (how far the user has scrolled along the FlatList) and the value of index (the index for each slide item). We'll apply a few transformations to the carousel slide that we'll eventually animate. For now, let's just do a little setup.

const CarouselSlide: React.FC<{
  slide: Slide;
  scrollX: Animated.Value;
  index: number;
}> = ({ slide, scrollX, index }) => {
  // Transforms
  const opacity = 1;
  const scale = 1;
  const perspective = 800;
  const translateX = 0;
  const rotateY = '0deg';

  return (
    <Animated.View
      style={[
        styles.cardContainer,
        {
          opacity,
          transform: [{ scale }, { perspective }, { translateX }, { rotateY }],
        },
      ]}
    >
      <ImageBackground source={slide.image} style={{ flex: 1 }}>
        <Spacer height={0.7 * height} />
        <View style={styles.cardContentContainer}>
          <AppText style={styles.title}>{slide.title}</AppText>
          <AppText style={styles.subtitle}>{slide.subtitle}</AppText>
        </View>
      </ImageBackground>
    </Animated.View>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the above code snippet, we're setting some base values for opacity, scale, perspective, translateX, and rotateY - and then applying these to our slide wrapper. We also turned the View into an Animated.View, since we'll be turning the aforementioned property values into animated values.

Now, let's talk about these properties that we'll be animating - and why.

  • We'll animate opacity so that as the slide comes in/out of view, we can add a bit of a "disappearing" effect.
  • We'll animate scale so that as the slide comes in/out of view, it shrinks/grows a little bit.
  • We'll animate perspective, translateX, and rotateY to give the slide a "tilt" effect. If you swipe the card out to the left, it should "tilt" around the left side of the screen. If you swipe the card out to the right, it should "tilt" around the right side of the screen.

Let's fill these animated values in, and then look at the logic behind them.

const inputRange = [(index - 1) * width, index * width, (index + 1) * width];

const opacity = scrollX.interpolate({
  inputRange,
  outputRange: [0, 1, 0],
});
const scale = scrollX.interpolate({
  inputRange,
  outputRange: [0.6, 1, 0.6],
});
const perspective = scrollX.interpolate({
  inputRange,
  outputRange: [1200, 800, 1200],
});
const translateX = Animated.subtract(scrollX, index * width);
const rotateY = scrollX.interpolate({
  inputRange,
  outputRange: ["-45deg", "0deg", "45deg"],
});
Enter fullscreen mode Exit fullscreen mode

At this point, we've created the "tilt" effect! But we need to loop back and take a look at how we constructed these animated values.

Animated Interpolation

React Native animated values have an interpolate method that allows us to transform an animated value. This is a tremendously powerful tool! We're going to be transforming our scrollX value. The interpolate method takes an inputRange and an outputRange that defines the transformation.

In our case, we have this line:

const inputRange = [(index - 1) * width, index * width, (index + 1) * width];
Enter fullscreen mode Exit fullscreen mode

We can think of this as three "breakpoints" that we'll map from relative to each slide. For example, for the first slide, we can think of this as follows:

Zones for input range

As you swipe left, scrollX will vary from index * width to (index - 1) * width. If you were to swipe right, it'd vary from index * width to (index + 1) * width. Therefore, we can think of this inputRange as defining the breakpoints for when the slide is all the way out of the screen's view (to the left), when the slide is fully centered in the screen's view, and all the way out of the screen's view (to the right).

We can then think about how to transform other values based on these "breakpoints". For example, when a slide is out of the screen's view (either to the left or to the right), we want it to "disappear" by applying opacity: 0. When the slide is in the screen's view, we want opacity: 1. Therefore, we can define opacity with the following transformation on scrollX:

const opacity = scrollX.interpolate({
  inputRange,
  outputRange: [0, 1, 0],
});
Enter fullscreen mode Exit fullscreen mode

In a similar manner, we want the slide to sort of "shrink" as it goes out of the view, so we can define:

const scale = scrollX.interpolate({
  inputRange,
  outputRange: [0.6, 1, 0.6],
});
Enter fullscreen mode Exit fullscreen mode

We can think of this like:

  • Slide is swiped out to the left? scale: 0.6.
  • Slide is fully in view? scale: 1.
  • Slide is swiped out to the right? scale: 0.6.
  • Smoothly animated everything in-between (using linear interpolation).

Transformations for the Tilt

Now all that's left to unwrap is:

const perspective = scrollX.interpolate({
  inputRange,
  outputRange: [1200, 800, 1200],
});
const translateX = Animated.subtract(scrollX, index * width);
const rotateY = scrollX.interpolate({
  inputRange,
  outputRange: ["-45deg", "0deg", "45deg"],
});
Enter fullscreen mode Exit fullscreen mode

I don't want to go into boring details with this, but to create the "tilt" effect around the edges of the screen, we need to perform three consecutive transformations (and order matters!). From a high-level, we can think of these transforms in the following way:

  • Use perspective to make it seem like the slide is being "pushed away" from the user as the slide is moved off of screen.
  • Use translateX to shift the slide left/right to determine the axis of rotation for the "tilt".
  • Use rotateY to create the rotation effect.

With these in place, we get a "tilt" effect! And that concludes this tutorial.

Conclusion

Although the whole "tilt" effect has some complicated math/transformations, I hope that the setup to these transformations is helpful and gives you the tools to start creating your own fancy animations on top of React Native FlatLists! For the purpose of thoroughness, here's the whole code:

import * as React from "react";
import {
  Animated,
  Dimensions,
  ImageBackground,
  StyleSheet,
  View,
} from "react-native";
import { AppText } from "../../components/AppText";
import { Spacer } from "../../components/Spacer";

// Set up our slides
type Slide = {
  image: any;
  title: string;
  subtitle: string;
};
const SLIDES: Slide[] = [
  {
    image: require("./img/summer.jpg"),
    title: "Summer",
    subtitle: "Warm days, fun nights.",
  },
  {
    image: require("./img/fall.jpg"),
    title: "Fall",
    subtitle: "Sweater weather, baby.",
  },
  {
    image: require("./img/winter.jpg"),
    title: "Winter",
    subtitle: "The season to be jolly.",
  },
  {
    image: require("./img/spring.jpg"),
    title: "Spring",
    subtitle: "April showers, may flowers.",
  },
];

// Utils
const { width, height } = Dimensions.get("window");

/**
 * Tilt Carousel View
 */
export const TiltCarousel: React.FC = () => {
  const scrollX = React.useRef(new Animated.Value(0)).current;

  return (
    <View style={styles.background}>
      <Animated.FlatList
        data={SLIDES}
        renderItem={({ item, index }: { item: Slide; index: number }) => (
          <CarouselSlide slide={item} scrollX={scrollX} index={index} />
        )}
        horizontal
        snapToInterval={width}
        showsHorizontalScrollIndicator={false}
        scrollEventThrottle={16}
        decelerationRate={-1}
        bounces={true}
        keyExtractor={(slide: Slide) => slide.title}
        onScroll={Animated.event(
          [{ nativeEvent: { contentOffset: { x: scrollX } } }],
          { useNativeDriver: true },
        )}
      />
    </View>
  );
};

/**
 * Slide item
 */
const CarouselSlide: React.FC<{
  slide: Slide;
  scrollX: Animated.Value;
  index: number;
}> = ({ slide, scrollX, index }) => {
  const inputRange = [(index - 1) * width, index * width, (index + 1) * width];

  const opacity = scrollX.interpolate({
    inputRange,
    outputRange: [0, 1, 0],
  });
  const scale = scrollX.interpolate({
    inputRange,
    outputRange: [0.6, 1, 0.6],
  });
  const perspective = scrollX.interpolate({
    inputRange,
    outputRange: [1200, 800, 1200],
  });
  const translateX = Animated.subtract(scrollX, index * width);
  const rotateY = scrollX.interpolate({
    inputRange,
    outputRange: ["-45deg", "0deg", "45deg"],
  });

  return (
    <Animated.View
      style={[
        styles.cardContainer,
        {
          opacity,
          transform: [{ scale }, { perspective }, { translateX }, { rotateY }],
        },
      ]}
    >
      <ImageBackground source={slide.image} style={{ flex: 1 }}>
        <Spacer height={0.7 * height} />
        <View style={styles.cardContentContainer}>
          <AppText style={styles.title}>{slide.title}</AppText>
          <AppText style={styles.subtitle}>{slide.subtitle}</AppText>
        </View>
      </ImageBackground>
    </Animated.View>
  );
};

/**
 * Styling
 */
const styles = StyleSheet.create({
  background: { flex: 1, backgroundColor: "rgba(30,30,30,0.8)" },
  cardContainer: {
    width,
    flex: 1,
    justifyContent: "center",
    borderRadius: 30,
    overflow: "hidden",
  },
  cardContentContainer: {
    alignItems: "center",
    backgroundColor: "rgba(0,0,0,0.3)",
    padding: 16,
  },
  title: {
    color: "white",
    fontWeight: "bold",
    fontSize: 64,
    textShadowColor: "black",
    textShadowRadius: 4,
    textShadowOffset: {
      width: 1,
      height: 1,
    },
  },
  subtitle: {
    color: "rgb(230,230,230)",
    fontWeight: "600",
    fontSize: 18,
  },
});
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
yusufff profile image
Yusuf YILDIZ

Thank you for the great article!
Here is an expo snack for this demo if anyone wants to try it: snack.expo.io/@ysfu/tilt-carousel